Add HBrowse

This commit is contained in:
NerdNumber9 2019-08-10 20:23:43 -04:00
parent 45fc2f2e0e
commit b4c1e6a44c
12 changed files with 1122 additions and 9 deletions

View File

@ -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 {

View File

@ -257,6 +257,16 @@
android:host="pururin.io"
android:pathPrefix="/gallery/"
android:scheme="https" />
<!-- HBrowse -->
<data
android:host="www.hbrowse.com"
android:pathPrefix="/"
android:scheme="http" />
<data
android:host="www.hbrowse.com"
android:pathPrefix="/"
android:scheme="https" />
</intent-filter>
</activity>
<activity

View File

@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.source.SourceManager
import exh.eh.EHentaiUpdateHelper
import io.noties.markwon.Markwon
import rx.Observable
import rx.schedulers.Schedulers
import uy.kohesive.injekt.api.*
@ -44,6 +45,8 @@ class AppModule(val app: Application) : InjektModule {
addSingletonFactory { EHentaiUpdateHelper(app) }
addSingletonFactory { Markwon.create(app) }
// Asynchronously init expensive components for a faster cold start
rxAsync { get<PreferencesHelper>() }

View File

@ -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
}

View File

@ -2,6 +2,10 @@ package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0)
// --> EXH
// name = button text
open class HelpDialog(name: String, val dialogTitle: String = name, val markdown: String) : Filter<Any>(name, 0)
// <-- EXH
open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)

View File

@ -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<HBrowseSearchMetadata, Document>, 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<MangasPage> {
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<MangasPage> {
return GlobalScope.async(Dispatchers.IO) {
val modeFilter = filters.filterIsInstance<ModeFilter>().firstOrNull()
val sortFilter = filters.filterIsInstance<SortFilter>().firstOrNull()
var base: String? = null
var isSortFilter = false
// <NS, VALUE, EXCLUDED>
var tagQuery: List<Triple<String, String, Boolean>>? = 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<String, String> = "" 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<String>.nearest(string: String, maxDist: Double = Int.MAX_VALUE.toDouble()): Pair<String, Double> {
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<SManga> {
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<SChapter> {
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<String, Map<String, Element>> {
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<Page> {
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<String>("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")
}
}

View File

@ -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)

View File

@ -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<HelpDialogItem.Holder>() {
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<IFlexible<RecyclerView.ViewHolder>>): Holder {
return Holder(view, adapter)
}
override fun bindViewHolder(adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>, holder: Holder, position: Int, payloads: List<Any?>?) {
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)
}
}

View File

@ -21,6 +21,7 @@ val PURURIN_SOURCE_ID = delegatedSourceId<Pururin>()
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(

View File

@ -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"
}
}
}

View File

@ -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<String, List<QueryComponent>>()
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<QueryComponent>()
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 == '-') {

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="?attr/listPreferredItemHeightSmall"
android:paddingLeft="?attr/listPreferredItemPaddingLeft"
android:paddingRight="?attr/listPreferredItemPaddingRight"
android:background="?attr/selectableItemBackground"
android:focusable="true">
<Button
android:id="@+id/dialog_open_button"
style="@style/Theme.Widget.Button.Borderless"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:text="Button" />
</LinearLayout>