From b4c1e6a44cb7415a2338e4a67d23091e86016ece Mon Sep 17 00:00:00 2001 From: NerdNumber9 Date: Sat, 10 Aug 2019 20:23:43 -0400 Subject: [PATCH] Add HBrowse --- app/build.gradle | 9 + app/src/main/AndroidManifest.xml | 10 + .../java/eu/kanade/tachiyomi/AppModule.kt | 3 + .../kanade/tachiyomi/source/SourceManager.kt | 6 +- .../kanade/tachiyomi/source/model/Filter.kt | 4 + .../source/online/english/HBrowse.kt | 955 ++++++++++++++++++ .../browse/BrowseCataloguePresenter.kt | 3 + .../ui/catalogue/filter/HelpDialogItem.kt | 61 ++ app/src/main/java/exh/EHSourceHelpers.kt | 1 + .../metadata/HBrowseSearchMetadata.kt | 52 + app/src/main/java/exh/search/SearchEngine.kt | 8 +- .../layout/navigation_view_help_dialog.xml | 19 + 12 files changed, 1122 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt create mode 100755 app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt create mode 100644 app/src/main/java/exh/metadata/metadata/HBrowseSearchMetadata.kt create mode 100755 app/src/main/res/layout/navigation_view_help_dialog.xml diff --git a/app/build.gradle b/app/build.gradle index 378301533..4e4e6ed68 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -303,6 +303,15 @@ dependencies { implementation 'com.github.mfornos:humanize-slim:1.2.2' implementation 'com.android.support:gridlayout-v7:28.0.0' + + final def markwon_version = '4.1.0' + + implementation "io.noties.markwon:core:$markwon_version" + implementation "io.noties.markwon:ext-strikethrough:$markwon_version" + implementation "io.noties.markwon:ext-tables:$markwon_version" + implementation "io.noties.markwon:html:$markwon_version" + implementation "io.noties.markwon:image:$markwon_version" + implementation "io.noties.markwon:linkify:$markwon_version" } buildscript { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bb0a9eb60..a0e4d43b8 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -257,6 +257,16 @@ android:host="pururin.io" android:pathPrefix="/gallery/" android:scheme="https" /> + + + + () } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 52d2dd8e7..f0364e127 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -10,10 +10,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.all.* -import eu.kanade.tachiyomi.source.online.english.EightMuses -import eu.kanade.tachiyomi.source.online.english.HentaiCafe -import eu.kanade.tachiyomi.source.online.english.Pururin -import eu.kanade.tachiyomi.source.online.english.Tsumino +import eu.kanade.tachiyomi.source.online.english.* import rx.Observable import exh.EH_SOURCE_ID import exh.EXH_SOURCE_ID @@ -116,6 +113,7 @@ open class SourceManager(private val context: Context) { exSrcs += Tsumino(context) exSrcs += Hitomi() exSrcs += EightMuses() + exSrcs += HBrowse() return exSrcs } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt index 1664d67eb..e3ca0ce39 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/Filter.kt @@ -2,6 +2,10 @@ package eu.kanade.tachiyomi.source.model sealed class Filter(val name: String, var state: T) { open class Header(name: String) : Filter(name, 0) + // --> EXH + // name = button text + open class HelpDialog(name: String, val dialogTitle: String = name, val markdown: String) : Filter(name, 0) + // <-- EXH open class Separator(name: String = "") : Filter(name, 0) abstract class Select(name: String, val values: Array, state: Int = 0) : Filter(name, state) abstract class Text(name: String, state: String = "") : Filter(name, state) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt new file mode 100644 index 000000000..44c01ac1c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/english/HBrowse.kt @@ -0,0 +1,955 @@ +package eu.kanade.tachiyomi.source.online.english + +import android.net.Uri +import com.github.salomonbrys.kotson.array +import com.github.salomonbrys.kotson.string +import com.google.gson.JsonParser +import com.lvla.rxjava.interopkt.toV1Single +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.source.online.LewdSource +import eu.kanade.tachiyomi.source.online.UrlImportableSource +import eu.kanade.tachiyomi.util.asJsoup +import exh.metadata.metadata.HBrowseSearchMetadata +import exh.metadata.metadata.base.RaisedTag +import exh.search.Namespace +import exh.search.SearchEngine +import exh.search.Text +import exh.util.await +import exh.util.dropBlank +import exh.util.urlImportFetchSearchManga +import info.debatty.java.stringsimilarity.Levenshtein +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import kotlinx.coroutines.rx2.asSingle +import okhttp3.* +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import rx.schedulers.Schedulers +import kotlin.math.ceil + +class HBrowse : HttpSource(), LewdSource, UrlImportableSource { + /** + * An ISO 639-1 compliant language code (two letters in lower case). + */ + override val lang: String = "en" + /** + * Base url of the website without the trailing slash, like: http://mysite.com + */ + override val baseUrl = HBrowseSearchMetadata.BASE_URL + + override val name: String = "HBrowse" + + override val supportsLatest = true + + override val metaClass = HBrowseSearchMetadata::class + + override fun headersBuilder() = Headers.Builder() + .add("Cookie", BASE_COOKIES) + + private val clientWithoutCookies = client.newBuilder() + .cookieJar(CookieJar.NO_COOKIES) + .build() + + private val nonRedirectingClientWithoutCookies = clientWithoutCookies.newBuilder() + .followRedirects(false) + .build() + + private val searchEngine = SearchEngine() + + /** + * Returns the request for the popular manga given the page. + * + * @param page the page number to retrieve. + */ + override fun popularMangaRequest(page: Int) + = GET("$baseUrl/browse/title/rank/DESC", headers) + + private fun parseListing(response: Response): MangasPage { + val doc = response.asJsoup() + val main = doc.selectFirst("#main") + val items = main.select(".thumbTable > tbody") + val manga = items.map { mangaEle -> + SManga.create().apply { + val thumbElement = mangaEle.selectFirst(".thumbImg") + url = "/" + thumbElement.parent().attr("href").split("/").dropBlank().first() + title = thumbElement.parent().attr("title").substringAfter('\'').substringBeforeLast('\'') + thumbnail_url = baseUrl + thumbElement.attr("src") + } + } + + val hasNextPage = doc.selectFirst("#main > p > a[title~=jump]:nth-last-child(1)") != null + return MangasPage( + manga, + hasNextPage + ) + } + + /** + * Returns an observable containing a page with a list of manga. Normally it's not needed to + * override this method. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return urlImportFetchSearchManga(query) { + fetchSearchMangaInternal(page, query, filters) + } + } + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun popularMangaParse(response: Response) = parseListing(response) + + /** + * Returns the request for the search manga given the page. + * + * @param page the page number to retrieve. + * @param query the search query. + * @param filters the list of filters to apply. + */ + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) + = throw UnsupportedOperationException("Should not be called!") + + private fun fetchSearchMangaInternal(page: Int, query: String, filters: FilterList): Observable { + return GlobalScope.async(Dispatchers.IO) { + val modeFilter = filters.filterIsInstance().firstOrNull() + val sortFilter = filters.filterIsInstance().firstOrNull() + + var base: String? = null + var isSortFilter = false + // + var tagQuery: List>? = null + + if(sortFilter != null) { + sortFilter.state?.let { state -> + if(query.isNotBlank()) { + throw IllegalArgumentException("Cannot use sorting while text/tag search is active!") + } + + isSortFilter = true + base = "/browse/title/${SortFilter.SORT_OPTIONS[state.index].first}/${if(state.ascending) "ASC" else "DESC"}" + } + } + + if(base == null) { + base = if(modeFilter != null && modeFilter.state == 1) { + tagQuery = searchEngine.parseQuery(query, false).map { + when (it) { + is Text -> { + var minDist = Int.MAX_VALUE.toDouble() + // ns, value + var minContent: Pair = "" to "" + for(ns in ALL_TAGS) { + val (v, d) = ns.value.nearest(query, minDist) + if(d < minDist) { + minDist = d + minContent = ns.key to v + } + } + minContent + } + is Namespace -> { + // Map ns aliases + val mappedNs = NS_MAPPINGS[it.namespace] ?: it.namespace + + var key = mappedNs + if(ALL_TAGS.containsKey(key)) key = ALL_TAGS.keys.sorted().nearest(mappedNs).first + + // Find nearest NS + val nsContents = ALL_TAGS[key] + + key to nsContents!!.nearest(it.tag?.rawTextOnly() ?: "").first + } + else -> error("Unknown type!") + }.let { p -> + Triple(p.first, p.second, it.excluded) + } + } + + + "/result" + } else { + "/search" + } + } + + base += "/$page" + + if(isSortFilter) { + parseListing(client.newCall(GET(baseUrl + base, headers)) + .asObservableSuccess() + .toSingle() + .await(Schedulers.io())) + } else { + val body = if(tagQuery != null) { + FormBody.Builder() + .add("type", "advance") + .apply { + tagQuery.forEach { + add(it.first + "_" + it.second, if(it.third) "n" else "y") + } + } + } else { + FormBody.Builder() + .add("type", "search") + .add("needle", query) + } + val processRequest = POST( + "$baseUrl/content/process.php", + headers, + body = body.build() + ) + val processResponse = nonRedirectingClientWithoutCookies.newCall(processRequest) + .asObservable() + .toSingle() + .await(Schedulers.io()) + + if(!processResponse.isRedirect) + throw IllegalStateException("Unexpected process response code!") + + val sessId = processResponse.headers("Set-Cookie").find { + it.startsWith("PHPSESSID") + } ?: throw IllegalStateException("Missing server session cookie!") + + val response = clientWithoutCookies.newCall(GET(baseUrl + base, + headersBuilder() + .set("Cookie", BASE_COOKIES + " " + sessId.substringBefore(';')) + .build())) + .asObservableSuccess() + .toSingle() + .await(Schedulers.io()) + + val doc = response.asJsoup() + val manga = doc.select(".browseDescription").map { + SManga.create().apply { + val first = it.child(0) + url = first.attr("href") + title = first.attr("title").substringAfter('\'').removeSuffix("'").replace('_', ' ') + thumbnail_url = HBrowseSearchMetadata.guessThumbnailUrl(url.substring(1)) + } + } + val hasNextPage = doc.selectFirst("#main > p > a[title~=jump]:nth-last-child(1)") != null + MangasPage( + manga, + hasNextPage + ) + } + }.asSingle(GlobalScope.coroutineContext).toV1Single().toObservable() + } + + // Collection must be sorted and cannot be sorted + private fun List.nearest(string: String, maxDist: Double = Int.MAX_VALUE.toDouble()): Pair { + val idx = binarySearch(string) + return if(idx < 0) { + val l = Levenshtein() + var minSoFar = maxDist + var minIndexSoFar = 0 + forEachIndexed { index, s -> + val d = l.distance(string, s, ceil(minSoFar).toInt()) + if(d < minSoFar) { + minSoFar = d + minIndexSoFar = index + } + } + get(minIndexSoFar) to minSoFar + } else { + get(idx) to 0.0 + } + } + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun searchMangaParse(response: Response) = parseListing(response) + + /** + * Returns the request for latest manga given the page. + * + * @param page the page number to retrieve. + */ + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/browse/title/date/DESC", headers) + + /** + * Parses the response from the site and returns a [MangasPage] object. + * + * @param response the response from the site. + */ + override fun latestUpdatesParse(response: Response) = parseListing(response) + + /** + * Parses the response from the site and returns the details of a manga. + * + * @param response the response from the site. + */ + override fun mangaDetailsParse(response: Response): SManga { + throw UnsupportedOperationException("Should not be called!") + } + + override fun parseIntoMetadata(metadata: HBrowseSearchMetadata, input: Document) { + val tables = parseIntoTables(input) + with(metadata) { + hbId = Uri.parse(input.location()).pathSegments.first().toLong() + + tags.clear() + (tables[""]!! + tables["categories"]!!).forEach { (k, v) -> + when(val lowercaseNs = k.toLowerCase()) { + "title" -> title = v.text() + "length" -> length = v.text().substringBefore(" ").toInt() + else -> { + v.getElementsByTag("a").forEach { + tags += RaisedTag( + lowercaseNs, + it.text(), + HBrowseSearchMetadata.TAG_TYPE_DEFAULT + ) + } + } + } + } + } + } + + /** + * Returns an observable with the updated details for a manga. Normally it's not needed to + * override this method. + * + * @param manga the manga to be updated. + */ + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsRequest(manga)) + .asObservableSuccess() + .flatMap { + parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga)) + } + } + + /** + * Parses the response from the site and returns a list of chapters. + * + * @param response the response from the site. + */ + override fun chapterListParse(response: Response): List { + return parseIntoTables(response.asJsoup())["read manga online"]?.map { (key, value) -> + SChapter.create().apply { + url = value.selectFirst(".listLink").attr("href") + + name = key + } + } ?: emptyList() + } + + private fun parseIntoTables(doc: Document): Map> { + return doc.select("#main > .listTable").map { ele -> + val tableName = ele.previousElementSibling()?.text()?.toLowerCase() ?: "" + tableName to ele.select("tr").map { + it.child(0).text() to it.child(1) + }.toMap() + }.toMap() + } + + /** + * Parses the response from the site and returns a list of pages. + * + * @param response the response from the site. + */ + override fun pageListParse(response: Response): List { + val doc = response.asJsoup() + val basePath = listOf("data") + response.request().url().pathSegments() + val scripts = doc.getElementsByTag("script").map { it.data() } + for(script in scripts) { + val totalPages = TOTAL_PAGES_REGEX.find(script)?.groupValues?.getOrNull(1)?.toIntOrNull() ?: continue + val pageList = PAGE_LIST_REGEX.find(script)?.groupValues?.getOrNull(1) ?: continue + + return jsonParser.parse(pageList).array.take(totalPages).map { + it.string + }.mapIndexed { index, pageName -> + Page( + index, + pageName, + "$baseUrl/${basePath.joinToString("/")}/$pageName" + ) + } + } + + return emptyList() + } + + class HelpFilter : Filter.HelpDialog("Usage instructions", markdown = """ + ### Modes + There are three available filter modes: + - Text search + - Tag search + - Sort mode + + You can only use a single mode at a time. Switch between the text and tag search modes using the dropdown menu. Switch to sorting mode by selecting a sorting option. + + ### Text search + Search for galleries by title, artist or origin. + + ### Tag search + Search for galleries by tag (e.g. search for a specific genre, type, setting, etc). Uses nhentai/e-hentai syntax. Refer to the "Search" section on [this page](https://nhentai.net/info/) for more information. + + ### Sort mode + View a list of all galleries sorted by a specific parameter. Exit sorting mode by resetting the filters using the reset button near the bottom of the screen. + + ### Tag list + """.trimIndent() + "\n$TAGS_AS_MARKDOWN") + + class ModeFilter : Filter.Select("Mode", arrayOf( + "Text search", + "Tag search" + )) + + class SortFilter : Filter.Sort("Sort", SORT_OPTIONS.map { it.second }.toTypedArray()) { + companion object { + // internal to display + val SORT_OPTIONS = listOf( + "length" to "Length", + "date" to "Date added", + "rank" to "Rank" + ) + } + } + + override fun getFilterList() = FilterList( + HelpFilter(), + ModeFilter(), + SortFilter() + ) + + /** + * Parses the response from the site and returns the absolute url to the source image. + * + * @param response the response from the site. + */ + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException("Should not be called!") + } + + override val matchingHosts = listOf( + "www.hbrowse.com", + "hbrowse.com" + ) + + override fun mapUrlToMangaUrl(uri: Uri): String? { + return "$baseUrl/${uri.pathSegments.first()}" + } + + companion object { + private val PAGE_LIST_REGEX = Regex("list *= *(\\[.*]);") + private val TOTAL_PAGES_REGEX = Regex("totalPages *= *([0-9]*);") + + private val jsonParser by lazy { JsonParser() } + + private const val BASE_COOKIES = "thumbnails=1;" + + private val NS_MAPPINGS = mapOf( + "set" to "setting", + "loc" to "setting", + "location" to "setting", + "fet" to "fetish", + "relation" to "relationship", + "male" to "malebody", + "female" to "femalebody", + "pos" to "position" + ) + + private val ALL_TAGS = mapOf( + "genre" to listOf( + "action", + "adventure", + "anime", + "bizarre", + "comedy", + "drama", + "fantasy", + "gore", + "historic", + "horror", + "medieval", + "modern", + "myth", + "psychological", + "romance", + "school_life", + "scifi", + "supernatural", + "video_game", + "visual_novel" + ), + "type" to listOf( + "anthology", + "bestiality", + "dandere", + "deredere", + "deviant", + "fully_colored", + "furry", + "futanari", + "gender_bender", + "guro", + "harem", + "incest", + "kuudere", + "lolicon", + "long_story", + "netorare", + "non-con", + "partly_colored", + "reverse_harem", + "ryona", + "short_story", + "shotacon", + "transgender", + "tsundere", + "uncensored", + "vanilla", + "yandere", + "yaoi", + "yuri" + ), + "setting" to listOf( + "amusement_park", + "attic", + "automobile", + "balcony", + "basement", + "bath", + "beach", + "bedroom", + "cabin", + "castle", + "cave", + "church", + "classroom", + "deck", + "dining_room", + "doctors", + "dojo", + "doorway", + "dream", + "dressing_room", + "dungeon", + "elevator", + "festival", + "gym", + "haunted_building", + "hospital", + "hotel", + "hot_springs", + "kitchen", + "laboratory", + "library", + "living_room", + "locker_room", + "mansion", + "office", + "other", + "outdoor", + "outer_space", + "park", + "pool", + "prison", + "public", + "restaurant", + "restroom", + "roof", + "sauna", + "school", + "school_nurses_office", + "shower", + "shrine", + "storage_room", + "store", + "street", + "teachers_lounge", + "theater", + "tight_space", + "toilet", + "train", + "transit", + "virtual_reality", + "warehouse", + "wilderness" + ), + "fetish" to listOf( + "androphobia", + "apron", + "assertive_girl", + "bikini", + "bloomers", + "breast_expansion", + "business_suit", + "chastity_device", + "chinese_dress", + "christmas", + "collar", + "corset", + "cosplay_(female)", + "cosplay_(male)", + "crossdressing_(female)", + "crossdressing_(male)", + "eye_patch", + "food", + "giantess", + "glasses", + "gothic_lolita", + "gyaru", + "gynophobia", + "high_heels", + "hot_pants", + "impregnation", + "kemonomimi", + "kimono", + "knee_high_socks", + "lab_coat", + "latex", + "leotard", + "lingerie", + "maid_outfit", + "mother_and_daughter", + "none", + "nonhuman_girl", + "olfactophilia", + "pregnant", + "rich_girl", + "school_swimsuit", + "shy_girl", + "sisters", + "sleeping_girl", + "sporty", + "stockings", + "strapon", + "student_uniform", + "swimsuit", + "tanned", + "tattoo", + "time_stop", + "twins_(coed)", + "twins_(female)", + "twins_(male)", + "uniform", + "wedding_dress" + ), + "role" to listOf( + "alien", + "android", + "angel", + "athlete", + "bride", + "bunnygirl", + "cheerleader", + "delinquent", + "demon", + "doctor", + "dominatrix", + "escort", + "foreigner", + "ghost", + "housewife", + "idol", + "magical_girl", + "maid", + "mamono", + "massagist", + "miko", + "mythical_being", + "neet", + "nekomimi", + "newlywed", + "ninja", + "normal", + "nun", + "nurse", + "office_lady", + "other", + "police", + "priest", + "princess", + "queen", + "school_nurse", + "scientist", + "sorcerer", + "student", + "succubus", + "teacher", + "tomboy", + "tutor", + "waitress", + "warrior", + "witch" + ), + "relationship" to listOf( + "acquaintance", + "anothers_daughter", + "anothers_girlfriend", + "anothers_mother", + "anothers_sister", + "anothers_wife", + "aunt", + "babysitter", + "childhood_friend", + "classmate", + "cousin", + "customer", + "daughter", + "daughter-in-law", + "employee", + "employer", + "enemy", + "fiance", + "friend", + "friends_daughter", + "friends_girlfriend", + "friends_mother", + "friends_sister", + "friends_wife", + "girlfriend", + "landlord", + "manager", + "master", + "mother", + "mother-in-law", + "neighbor", + "niece", + "none", + "older_sister", + "patient", + "pet", + "physician", + "relative", + "relatives_friend", + "relatives_girlfriend", + "relatives_wife", + "servant", + "server", + "sister-in-law", + "slave", + "stepdaughter", + "stepmother", + "stepsister", + "stranger", + "student", + "teacher", + "tutee", + "tutor", + "twin", + "underclassman", + "upperclassman", + "wife", + "workmate", + "younger_sister" + ), + "malebody" to listOf( + "adult", + "animal", + "animal_ears", + "bald", + "beard", + "dark_skin", + "elderly", + "exaggerated_penis", + "fat", + "furry", + "goatee", + "hairy", + "half_animal", + "horns", + "large_penis", + "long_hair", + "middle_age", + "monster", + "muscular", + "mustache", + "none", + "short", + "short_hair", + "skinny", + "small_penis", + "tail", + "tall", + "tanned", + "tan_line", + "teenager", + "wings", + "young" + ), + "femalebody" to listOf( + "adult", + "animal_ears", + "bald", + "big_butt", + "chubby", + "dark_skin", + "elderly", + "elf_ears", + "exaggerated_breasts", + "fat", + "furry", + "hairy", + "hair_bun", + "half_animal", + "halo", + "hime_cut", + "horns", + "large_breasts", + "long_hair", + "middle_age", + "monster_girl", + "muscular", + "none", + "pigtails", + "ponytail", + "short", + "short_hair", + "skinny", + "small_breasts", + "tail", + "tall", + "tanned", + "tan_line", + "teenager", + "twintails", + "wings", + "young" + ), + "grouping" to listOf( + "foursome_(1_female)", + "foursome_(1_male)", + "foursome_(mixed)", + "foursome_(only_female)", + "one_on_one", + "one_on_one_(2_females)", + "one_on_one_(2_males)", + "orgy_(1_female)", + "orgy_(1_male)", + "orgy_(mainly_female)", + "orgy_(mainly_male)", + "orgy_(mixed)", + "orgy_(only_female)", + "orgy_(only_male)", + "solo_(female)", + "solo_(male)", + "threesome_(1_female)", + "threesome_(1_male)", + "threesome_(only_female)", + "threesome_(only_male)" + ), + "scene" to listOf( + "adultery", + "ahegao", + "anal_(female)", + "anal_(male)", + "aphrodisiac", + "armpit_sex", + "asphyxiation", + "blackmail", + "blowjob", + "bondage", + "breast_feeding", + "breast_sucking", + "bukkake", + "cheating_(female)", + "cheating_(male)", + "chikan", + "clothed_sex", + "consensual", + "cunnilingus", + "defloration", + "discipline", + "dominance", + "double_penetration", + "drunk", + "enema", + "exhibitionism", + "facesitting", + "fingering_(female)", + "fingering_(male)", + "fisting", + "footjob", + "grinding", + "groping", + "handjob", + "humiliation", + "hypnosis", + "intercrural", + "interracial_sex", + "interspecies_sex", + "lactation", + "lotion", + "masochism", + "masturbation", + "mind_break", + "nonhuman", + "orgy", + "paizuri", + "phone_sex", + "props", + "rape", + "reverse_rape", + "rimjob", + "sadism", + "scat", + "sex_toys", + "spanking", + "squirt", + "submission", + "sumata", + "swingers", + "tentacles", + "voyeurism", + "watersports", + "x-ray_blowjob", + "x-ray_sex" + ), + "position" to listOf( + "69", + "acrobat", + "arch", + "bodyguard", + "butterfly", + "cowgirl", + "dancer", + "deck_chair", + "deep_stick", + "doggy", + "drill", + "ex_sex", + "jockey", + "lap_dance", + "leg_glider", + "lotus", + "mastery", + "missionary", + "none", + "other", + "pile_driver", + "prison_guard", + "reverse_piggyback", + "rodeo", + "spoons", + "standing", + "teaspoons", + "unusual", + "victory" + ) + ).mapValues { it.value.sorted() } + + private val TAGS_AS_MARKDOWN = ALL_TAGS.map { (ns, values) -> + "#### $ns\n" + values.map { "- $it" }.joinToString("\n") }.joinToString("\n\n") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt index dfcdb8d83..cf06048c8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/browse/BrowseCataloguePresenter.kt @@ -292,6 +292,9 @@ open class BrowseCataloguePresenter( return mapNotNull { when (it) { is Filter.Header -> HeaderItem(it) + // --> EXH + is Filter.HelpDialog -> HelpDialogItem(it) + // <-- EXH is Filter.Separator -> SeparatorItem(it) is Filter.CheckBox -> CheckboxItem(it) is Filter.TriState -> TriStateItem(it) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt new file mode 100755 index 000000000..539c93d51 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/catalogue/filter/HelpDialogItem.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.ui.catalogue.filter + +import android.annotation.SuppressLint +import android.support.v7.widget.RecyclerView +import android.view.View +import android.widget.Button +import android.widget.TextView +import com.afollestad.materialdialogs.MaterialDialog +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractHeaderItem +import eu.davidea.flexibleadapter.items.IFlexible +import eu.davidea.viewholders.FlexibleViewHolder +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.source.model.Filter +import io.noties.markwon.Markwon +import uy.kohesive.injekt.injectLazy + +class HelpDialogItem(val filter: Filter.HelpDialog) : AbstractHeaderItem() { + private val markwon: Markwon by injectLazy() + + @SuppressLint("PrivateResource") + override fun getLayoutRes(): Int { + return R.layout.navigation_view_help_dialog + } + + override fun createViewHolder(view: View, adapter: FlexibleAdapter>): Holder { + return Holder(view, adapter) + } + + override fun bindViewHolder(adapter: FlexibleAdapter>, holder: Holder, position: Int, payloads: List?) { + val view = holder.button as TextView + view.text = filter.name + view.setOnClickListener { + val v = TextView(view.context) + + val parsed = markwon.parse(filter.markdown) + val rendered = markwon.render(parsed) + markwon.setParsedMarkdown(v, rendered) + + MaterialDialog.Builder(view.context) + .title(filter.dialogTitle) + .customView(v, true) + .positiveText("Ok") + .show() + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return filter == (other as HelpDialogItem).filter + } + + override fun hashCode(): Int { + return filter.hashCode() + } + + class Holder(view: View, adapter: FlexibleAdapter<*>) : FlexibleViewHolder(view, adapter) { + val button: Button = itemView.findViewById(R.id.dialog_open_button) + } +} diff --git a/app/src/main/java/exh/EHSourceHelpers.kt b/app/src/main/java/exh/EHSourceHelpers.kt index 3d72b0c9b..009cdfa24 100755 --- a/app/src/main/java/exh/EHSourceHelpers.kt +++ b/app/src/main/java/exh/EHSourceHelpers.kt @@ -21,6 +21,7 @@ val PURURIN_SOURCE_ID = delegatedSourceId() const val TSUMINO_SOURCE_ID = LEWD_SOURCE_SERIES + 9 const val HITOMI_SOURCE_ID = LEWD_SOURCE_SERIES + 10 const val EIGHTMUSES_SOURCE_ID = LEWD_SOURCE_SERIES + 11 +const val HBROWSE_SOURCE_ID = LEWD_SOURCE_SERIES + 12 const val MERGED_SOURCE_ID = LEWD_SOURCE_SERIES + 69 private val DELEGATED_LEWD_SOURCES = listOf( diff --git a/app/src/main/java/exh/metadata/metadata/HBrowseSearchMetadata.kt b/app/src/main/java/exh/metadata/metadata/HBrowseSearchMetadata.kt new file mode 100644 index 000000000..6bad5fcfb --- /dev/null +++ b/app/src/main/java/exh/metadata/metadata/HBrowseSearchMetadata.kt @@ -0,0 +1,52 @@ +package exh.metadata.metadata + +import eu.kanade.tachiyomi.source.model.SManga +import exh.metadata.metadata.EightMusesSearchMetadata.Companion.ARTIST_NAMESPACE +import exh.metadata.metadata.base.RaisedSearchMetadata +import exh.plusAssign + +class HBrowseSearchMetadata : RaisedSearchMetadata() { + var hbId: Long? = null + + var title: String? by titleDelegate(TITLE_TYPE_MAIN) + + // Length in pages + var length: Int? = null + + override fun copyTo(manga: SManga) { + manga.url = "/$hbId" + + title?.let { + manga.title = it + } + + // Guess thumbnail URL if manga does not have thumbnail URL + if(manga.thumbnail_url.isNullOrBlank()) { + manga.thumbnail_url = guessThumbnailUrl(hbId.toString()) + } + + manga.artist = tags.ofNamespace(ARTIST_NAMESPACE).joinToString { it.name } + + val titleDesc = StringBuilder() + title?.let { titleDesc += "Title: $it\n" } + length?.let { titleDesc += "Length: $it page(s)\n" } + + val tagsDesc = tagsToDescription() + + manga.description = listOf(titleDesc.toString(), tagsDesc.toString()) + .filter(String::isNotBlank) + .joinToString(separator = "\n") + } + + companion object { + const val BASE_URL = "https://www.hbrowse.com" + + private const val TITLE_TYPE_MAIN = 0 + + const val TAG_TYPE_DEFAULT = 0 + + fun guessThumbnailUrl(hbid: String): String { + return "$BASE_URL/thumbnails/${hbid}_1.jpg#guessed" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/exh/search/SearchEngine.kt b/app/src/main/java/exh/search/SearchEngine.kt index 07c18afc3..5cecac007 100755 --- a/app/src/main/java/exh/search/SearchEngine.kt +++ b/app/src/main/java/exh/search/SearchEngine.kt @@ -1,12 +1,10 @@ package exh.search -import eu.kanade.tachiyomi.data.database.tables.MangaTable import exh.metadata.sql.tables.SearchMetadataTable import exh.metadata.sql.tables.SearchTagTable import exh.metadata.sql.tables.SearchTitleTable class SearchEngine { - private val queryCache = mutableMapOf>() fun textToSubQueries(namespace: String?, @@ -116,7 +114,7 @@ class SearchEngine { return baseQuery to completeParams } - fun parseQuery(query: String) = queryCache.getOrPut(query) { + fun parseQuery(query: String, enableWildcard: Boolean = true) = queryCache.getOrPut(query) { val res = mutableListOf() var inQuotes = false @@ -155,10 +153,10 @@ class SearchEngine { for(char in query.toLowerCase()) { if(char == '"') { inQuotes = !inQuotes - } else if(char == '?' || char == '_') { + } else if(enableWildcard && (char == '?' || char == '_')) { flushText() queuedText.add(SingleWildcard(char.toString())) - } else if(char == '*' || char == '%') { + } else if(enableWildcard && (char == '*' || char == '%')) { flushText() queuedText.add(MultiWildcard(char.toString())) } else if(char == '-') { diff --git a/app/src/main/res/layout/navigation_view_help_dialog.xml b/app/src/main/res/layout/navigation_view_help_dialog.xml new file mode 100755 index 000000000..e5fe60808 --- /dev/null +++ b/app/src/main/res/layout/navigation_view_help_dialog.xml @@ -0,0 +1,19 @@ + + + + +