package exh.ui.smartsearch import android.os.Bundle import com.elvishew.xlog.XLog import eu.kanade.tachiyomi.data.database.DatabaseHelper import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter import eu.kanade.tachiyomi.ui.catalogue.CatalogueController import exh.util.await import info.debatty.java.stringsimilarity.NormalizedLevenshtein import kotlinx.coroutines.* 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?): BasePresenter(), CoroutineScope { private val db: DatabaseHelper by injectLazy() private val logger = XLog.tag("SmartSearchPresenter") override val coroutineContext = Job() + Dispatchers.Main val smartSearchChannel = Channel() override fun onCreate(savedState: Bundle?) { super.onCreate(savedState) if(source != null && config != null) { launch(Dispatchers.Default) { val result = try { val resultManga = smartSearch(source, config) if (resultManga != null) { val localManga = networkToLocalManga(resultManga, source.id) SearchResults.Found(localManga) } else { SearchResults.NotFound } } catch (e: Exception) { if (e is CancellationException) { throw e } else { logger.e("Smart search error", e) SearchResults.Error } } smartSearchChannel.send(result) } } } private suspend fun smartSearch(source: CatalogueSource, config: CatalogueController.SmartSearchConfig): SManga? { val cleanedTitle = cleanSmartSearchTitle(config.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) SearchEntry(it, normalizedDistance) }.filter { (_, normalizedDistance) -> normalizedDistance >= MIN_ELIGIBLE_THRESHOLD } } }.flatMap { it.await() } } return eligibleManga.maxBy { it.dist }?.manga } private fun getSmartSearchQueries(cleanedTitle: String): List { 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() { super.onDestroy() cancel() } data class SearchEntry(val manga: SManga, val dist: Double) sealed class SearchResults { data class Found(val manga: Manga): SearchResults() object NotFound: SearchResults() object Error: SearchResults() } companion object { const val MIN_ELIGIBLE_THRESHOLD = 0.7 private val titleRegex = Regex("[^a-zA-Z0-9- ]") private val consecutiveSpacesRegex = Regex(" +") } }