Initial work on SmartSearch

This commit is contained in:
NerdNumber9 2019-07-29 02:12:30 -04:00
parent b5263a6968
commit 8934d251d9
12 changed files with 361 additions and 15 deletions

View File

@ -253,9 +253,13 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
final coroutines_version = '1.2.0' final coroutines_version = '1.3.0-RC'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$coroutines_version"
// Text distance (EH)
implementation 'info.debatty:java-string-similarity:1.2.1'
// Pin lock view (EH) // Pin lock view (EH)
implementation 'com.andrognito.pinlockview:pinlockview:2.1.0' implementation 'com.andrognito.pinlockview:pinlockview:2.1.0'

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.ui.catalogue package eu.kanade.tachiyomi.ui.catalogue
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.os.Bundle
import android.os.Parcelable
import android.support.v7.widget.LinearLayoutManager import android.support.v7.widget.LinearLayoutManager
import android.support.v7.widget.SearchView import android.support.v7.widget.SearchView
import android.view.* import android.view.*
@ -23,6 +25,8 @@ import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController import eu.kanade.tachiyomi.ui.catalogue.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController import eu.kanade.tachiyomi.ui.setting.SettingsSourcesController
import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog import eu.kanade.tachiyomi.widget.preference.SourceLoginDialog
import exh.ui.smartsearch.SmartSearchController
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.catalogue_main_controller.* import kotlinx.android.synthetic.main.catalogue_main_controller.*
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -34,7 +38,7 @@ import uy.kohesive.injekt.api.get
* [CatalogueAdapter.OnBrowseClickListener] call function data on browse item click. * [CatalogueAdapter.OnBrowseClickListener] call function data on browse item click.
* [CatalogueAdapter.OnLatestClickListener] call function data on latest item click * [CatalogueAdapter.OnLatestClickListener] call function data on latest item click
*/ */
class CatalogueController : NucleusController<CataloguePresenter>(), class CatalogueController(bundle: Bundle? = null) : NucleusController<CataloguePresenter>(bundle),
SourceLoginDialog.Listener, SourceLoginDialog.Listener,
FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemClickListener,
CatalogueAdapter.OnBrowseClickListener, CatalogueAdapter.OnBrowseClickListener,
@ -50,12 +54,18 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
*/ */
private var adapter: CatalogueAdapter? = null private var adapter: CatalogueAdapter? = null
private val smartSearchConfig: SmartSearchConfig? = args.getParcelable(SMART_SEARCH_CONFIG)
// EXH -->
private val mode = if(smartSearchConfig == null) Mode.CATALOGUE else Mode.SMART_SEARCH
// EXH <--
/** /**
* Called when controller is initialized. * Called when controller is initialized.
*/ */
init { init {
// Enable the option menu // Enable the option menu
setHasOptionsMenu(true) setHasOptionsMenu(mode == Mode.CATALOGUE)
} }
/** /**
@ -64,7 +74,10 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
* @return title. * @return title.
*/ */
override fun getTitle(): String? { override fun getTitle(): String? {
return applicationContext?.getString(R.string.label_catalogues) return when(mode) {
Mode.CATALOGUE -> applicationContext?.getString(R.string.label_catalogues)
Mode.SMART_SEARCH -> "Find in another source"
}
} }
/** /**
@ -73,7 +86,7 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
* @return instance of [CataloguePresenter] * @return instance of [CataloguePresenter]
*/ */
override fun createPresenter(): CataloguePresenter { override fun createPresenter(): CataloguePresenter {
return CataloguePresenter() return CataloguePresenter(controllerMode = mode)
} }
/** /**
@ -140,8 +153,16 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
dialog.targetController = this dialog.targetController = this
dialog.showDialog(router) dialog.showDialog(router)
} else { } else {
// Open the catalogue view. when(mode) {
openCatalogue(source, BrowseCatalogueController(source)) Mode.CATALOGUE -> {
// Open the catalogue view.
openCatalogue(source, BrowseCatalogueController(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 return false
} }
@ -233,4 +254,18 @@ class CatalogueController : NucleusController<CataloguePresenter>(),
} }
class SettingsSourcesFadeChangeHandler : FadeChangeHandler() class SettingsSourcesFadeChangeHandler : FadeChangeHandler()
// EXH -->
@Parcelize
data class SmartSearchConfig(val title: String) : Parcelable
// EXH <--
enum class Mode {
CATALOGUE,
SMART_SEARCH
}
companion object {
const val SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG"
}
} }

View File

@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
*/ */
class CataloguePresenter( class CataloguePresenter(
val sourceManager: SourceManager = Injekt.get(), val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get() private val preferences: PreferencesHelper = Injekt.get(),
private val controllerMode: CatalogueController.Mode
) : BasePresenter<CatalogueController>() { ) : BasePresenter<CatalogueController>() {
/** /**
@ -62,7 +63,7 @@ class CataloguePresenter(
val byLang = sources.groupByTo(map, { it.lang }) val byLang = sources.groupByTo(map, { it.lang })
val sourceItems = byLang.flatMap { val sourceItems = byLang.flatMap {
val langItem = LangItem(it.key) val langItem = LangItem(it.key)
it.value.map { source -> SourceItem(source, langItem) } it.value.map { source -> SourceItem(source, langItem, controllerMode == CatalogueController.Mode.CATALOGUE) }
} }
sourceSubscription = Observable.just(sourceItems) sourceSubscription = Observable.just(sourceItems)
@ -77,7 +78,7 @@ class CataloguePresenter(
sharedObs.take(1), sharedObs.take(1),
sharedObs.skip(1).delay(500, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())) sharedObs.skip(1).delay(500, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()))
.distinctUntilChanged() .distinctUntilChanged()
.map { (sourceManager.get(it) as? CatalogueSource)?.let { SourceItem(it) } } .map { (sourceManager.get(it) as? CatalogueSource)?.let { SourceItem(it, showButtons = controllerMode == CatalogueController.Mode.CATALOGUE) } }
.subscribeLatestCache(CatalogueController::setLastUsedSource) .subscribeLatestCache(CatalogueController::setLastUsedSource)
} }

View File

@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.util.visible
import io.github.mthli.slice.Slice import io.github.mthli.slice.Slice
import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.* import kotlinx.android.synthetic.main.catalogue_main_controller_card_item.*
class SourceHolder(view: View, override val adapter: CatalogueAdapter) : class SourceHolder(view: View, override val adapter: CatalogueAdapter, val showButtons: Boolean) :
BaseFlexibleViewHolder(view, adapter), BaseFlexibleViewHolder(view, adapter),
SlicedHolder { SlicedHolder {
@ -30,6 +30,11 @@ class SourceHolder(view: View, override val adapter: CatalogueAdapter) :
source_latest.setOnClickListener { source_latest.setOnClickListener {
adapter.latestClickListener.onLatestClick(adapterPosition) adapter.latestClickListener.onLatestClick(adapterPosition)
} }
if(!showButtons) {
source_browse.gone()
source_latest.gone()
}
} }
fun bind(item: SourceItem) { fun bind(item: SourceItem) {
@ -50,7 +55,7 @@ class SourceHolder(view: View, override val adapter: CatalogueAdapter) :
source_latest.gone() source_latest.gone()
} else { } else {
source_browse.setText(R.string.browse) source_browse.setText(R.string.browse)
if (source.supportsLatest) { if (source.supportsLatest && showButtons) {
source_latest.visible() source_latest.visible()
} else { } else {
source_latest.gone() source_latest.gone()

View File

@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource
* @param source Instance of [CatalogueSource] containing source information. * @param source Instance of [CatalogueSource] containing source information.
* @param header The header for this item. * @param header The header for this item.
*/ */
data class SourceItem(val source: CatalogueSource, val header: LangItem? = null) : data class SourceItem(val source: CatalogueSource, val header: LangItem? = null, val showButtons: Boolean) :
AbstractSectionableItem<SourceHolder, LangItem>(header) { AbstractSectionableItem<SourceHolder, LangItem>(header) {
/** /**
@ -28,7 +28,7 @@ data class SourceItem(val source: CatalogueSource, val header: LangItem? = null)
* Creates a new view holder for this item. * Creates a new view holder for this item.
*/ */
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceHolder { override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceHolder {
return SourceHolder(view, adapter as CatalogueAdapter) return SourceHolder(view, adapter as CatalogueAdapter, showButtons)
} }
/** /**

View File

@ -38,6 +38,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.NucleusController import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController import eu.kanade.tachiyomi.ui.catalogue.global_search.CatalogueSearchController
import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog import eu.kanade.tachiyomi.ui.library.ChangeMangaCategoriesDialog
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
@ -160,6 +161,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) { when (item.itemId) {
// EXH -->
R.id.action_smart_search -> openSmartSearch()
// EXH <--
R.id.action_open_in_browser -> openInBrowser() R.id.action_open_in_browser -> openInBrowser()
R.id.action_open_in_web_view -> openInWebView() R.id.action_open_in_web_view -> openInWebView()
R.id.action_share -> shareManga() R.id.action_share -> shareManga()
@ -169,6 +173,17 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
return true return true
} }
// EXH -->
private fun openSmartSearch() {
val smartSearchConfig = CatalogueController.SmartSearchConfig(presenter.manga.title)
parentController?.router?.pushController(CatalogueController(Bundle().apply {
putParcelable(CatalogueController.SMART_SEARCH_CONFIG, smartSearchConfig)
}).withFadeTransaction())
}
// EXH <--
/** /**
* Check if manga is initialized. * Check if manga is initialized.
* If true update view with manga information, * If true update view with manga information,

View File

@ -0,0 +1,214 @@
package exh.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.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.source.model.FilterList
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
import eu.kanade.tachiyomi.ui.catalogue.CatalogueController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.toast
import exh.util.await
import info.debatty.java.stringsimilarity.NormalizedLevenshtein
import kotlinx.android.synthetic.main.eh_smart_search.*
import kotlinx.coroutines.*
import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy
import kotlin.text.StringBuilder
class SmartSearchController(bundle: Bundle? = null) : NucleusController<SmartSearchPresenter>() {
private val sourceManager: SourceManager by injectLazy()
private val db: DatabaseHelper by injectLazy()
private val source = sourceManager.get(bundle?.getLong(ARG_SOURCE_ID, -1) ?: -1) as? CatalogueSource
private val smartSearchConfig: CatalogueController.SmartSearchConfig? = bundle?.getParcelable(ARG_SMART_SEARCH_CONFIG)
override fun inflateView(inflater: LayoutInflater, container: ViewGroup) =
inflater.inflate(R.layout.eh_smart_search, container, false)!!
override fun getTitle() = source?.name ?: ""
override fun createPresenter() = SmartSearchPresenter()
override fun onViewCreated(view: View) {
super.onViewCreated(view)
appbar.bringToFront()
if(source == null || smartSearchConfig == null) {
router.popCurrentController()
applicationContext?.toast("Missing data!")
return
}
// TODO Error handling
// TODO Use activity scope
GlobalScope.launch(Dispatchers.IO) {
val resultManga = initiateSmartSearch(source, smartSearchConfig)
if(resultManga != null) {
val localManga = networkToLocalManga(resultManga, source.id)
val transaction = MangaController(localManga, true).withFadeTransaction()
withContext(Dispatchers.Main) {
router.replaceTopController(transaction)
}
} else {
// TODO Open search
router.popCurrentController()
}
println(resultManga)
}
}
suspend fun initiateSmartSearch(source: CatalogueSource, config: CatalogueController.SmartSearchConfig): SManga? {
val cleanedTitle = cleanSmartSearchTitle(config.title)
val queries = getSmartSearchQueries(cleanedTitle)
val eligibleManga = supervisorScope {
queries.map { query ->
async(Dispatchers.IO) {
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
}
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()
}
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
}
data class SearchEntry(val manga: SManga, val dist: Double)
companion object {
const val ARG_SOURCE_ID = "SOURCE_ID"
const val ARG_SMART_SEARCH_CONFIG = "SMART_SEARCH_CONFIG"
const val MIN_ELIGIBLE_THRESHOLD = 0.7
private val titleRegex = Regex("[^a-zA-Z0-9- ]")
private val consecutiveSpacesRegex = Regex(" +")
}
}

View File

@ -0,0 +1,6 @@
package exh.ui.smartsearch
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
class SmartSearchPresenter: BasePresenter<SmartSearchPresenter>() {
}

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M11,6c1.38,0 2.63,0.56 3.54,1.46L12,10h6L18,4l-2.05,2.05C14.68,4.78 12.93,4 11,4c-3.53,0 -6.43,2.61 -6.92,6L6.1,10c0.46,-2.28 2.48,-4 4.9,-4zM16.64,15.14c0.66,-0.9 1.12,-1.97 1.28,-3.14L15.9,12c-0.46,2.28 -2.48,4 -4.9,4 -1.38,0 -2.63,-0.56 -3.54,-1.46L10,12L4,12v6l2.05,-2.05C7.32,17.22 9.07,18 11,18c1.55,0 2.98,-0.51 4.14,-1.36L20,21.49 21.49,20l-4.85,-4.86z"/>
</vector>

View File

@ -20,7 +20,6 @@
<android.support.v7.widget.Toolbar <android.support.v7.widget.Toolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary" android:background="?attr/colorPrimary"

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<android.support.design.widget.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:elevation="0dp">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:theme="?attr/actionBarTheme" />
</android.support.design.widget.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="?attr/colorPrimary"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/intercept_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Searching source..."
android:textAppearance="@style/TextAppearance.Medium.Title"
android:textColor="@color/white" />
<ProgressBar
android:id="@+id/intercept_progress"
style="?android:attr/progressBarStyleLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminateTint="@color/white" />
</LinearLayout>
</LinearLayout>
</android.support.design.widget.CoordinatorLayout>

View File

@ -8,6 +8,11 @@
android:title="@string/action_share" android:title="@string/action_share"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item android:id="@+id/action_smart_search"
android:icon="@drawable/eh_ic_find_replace_white_24dp"
android:title="Find in another source"
app:showAsAction="ifRoom" />
<item android:id="@+id/action_open_in_browser" <item android:id="@+id/action_open_in_browser"
android:title="@string/action_open_in_browser" android:title="@string/action_open_in_browser"
app:showAsAction="never"/> app:showAsAction="never"/>