Rewrite tag searching to use SQL
Fix EHentai/ExHentai Fix hitomi.la Fix hitomi.la crashing application Rewrite hitomi.la search engine to be faster, use less CPU and require no preloading Fix nhentai Add additional filters to nhentai Fix PervEden Introduce delegated sources Rewrite HentaiCafe to be a delegated source Introduce ability to save/load search presets Temporarily disable misbehaving native Tachiyomi migrations Fix tap-to-search-tag breaking on aliased tags Add debug menu Add experimental automatic captcha solver Add app name to wakelock names Add ability to interrupt metadata migrator Fix incognito open-in-browser being zoomed in immediately when it's opened
This commit is contained in:
parent
5fbe1a8614
commit
603fd84753
4
.gitignore
vendored
4
.gitignore
vendored
@ -9,4 +9,6 @@
|
||||
*/build
|
||||
/mainframer
|
||||
/.mainframer
|
||||
*.apk
|
||||
*.apk
|
||||
TODO.md
|
||||
CHANGELOG.md
|
@ -241,6 +241,7 @@ dependencies {
|
||||
testImplementation "org.robolectric:shadows-play-services:$robolectric_version"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
||||
|
||||
final coroutines_version = '0.22.2'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
@ -262,6 +263,11 @@ dependencies {
|
||||
// Debug network interceptor (EH)
|
||||
devImplementation "com.squareup.okhttp3:logging-interceptor:3.10.0"
|
||||
|
||||
// Serialization
|
||||
implementation ("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.+") {
|
||||
exclude group: 'org.jetbrains.kotlin', module: 'kotlin-reflect'
|
||||
}
|
||||
|
||||
// Firebase (EH)
|
||||
implementation 'com.google.firebase:firebase-perf:16.0.0'
|
||||
implementation 'com.crashlytics.sdk.android:crashlytics:2.9.4'
|
||||
|
4
app/proguard-rules.pro
vendored
4
app/proguard-rules.pro
vendored
@ -93,6 +93,10 @@
|
||||
|
||||
# [EH]
|
||||
-keep class exh.** { *; }
|
||||
-dontwarn com.fasterxml.jackson.databind.ext.DOMSerializer
|
||||
-dontwarn com.fasterxml.jackson.databind.ext.Java7SupportImpl
|
||||
-dontwarn com.fasterxml.jackson.module.kotlin.KotlinNamesAnnotationIntrospector$hasCreatorAnnotation$1
|
||||
-dontwarn com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator
|
||||
|
||||
# Realm
|
||||
-dontnote rx.internal.util.PlatformDependent
|
||||
|
@ -100,7 +100,7 @@
|
||||
<activity
|
||||
android:name="exh.ui.intercept.InterceptActivity"
|
||||
android:label="TachiyomiEH"
|
||||
android:theme="@style/Theme.EHIntercept">
|
||||
android:theme="@style/Theme.EHActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@ -191,7 +191,8 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name="exh.ui.captcha.SolveCaptchaActivity"
|
||||
android:launchMode="singleInstance" />
|
||||
android:launchMode="singleInstance"
|
||||
android:theme="@style/Theme.EHActivity" />
|
||||
<activity android:name="exh.ui.webview.WebViewActivity"></activity>
|
||||
</application>
|
||||
|
||||
|
@ -8,6 +8,8 @@ import java.io.File
|
||||
|
||||
object Migrations {
|
||||
|
||||
// TODO NATIVE TACHIYOMI MIGRATIONS ARE FUCKED UP DUE TO DIFFERING VERSION NUMBERS
|
||||
|
||||
/**
|
||||
* Performs a migration when the application is updated.
|
||||
*
|
||||
|
@ -6,18 +6,28 @@ import com.pushtorefresh.storio.sqlite.impl.DefaultStorIOSQLite
|
||||
import eu.kanade.tachiyomi.data.database.mappers.*
|
||||
import eu.kanade.tachiyomi.data.database.models.*
|
||||
import eu.kanade.tachiyomi.data.database.queries.*
|
||||
import exh.metadata.sql.mappers.SearchMetadataTypeMapping
|
||||
import exh.metadata.sql.mappers.SearchTagTypeMapping
|
||||
import exh.metadata.sql.mappers.SearchTitleTypeMapping
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.metadata.sql.queries.SearchMetadataQueries
|
||||
import exh.metadata.sql.queries.SearchTagQueries
|
||||
import exh.metadata.sql.queries.SearchTitleQueries
|
||||
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
|
||||
|
||||
/**
|
||||
* This class provides operations to manage the database through its interfaces.
|
||||
*/
|
||||
open class DatabaseHelper(context: Context)
|
||||
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries {
|
||||
|
||||
: MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries,
|
||||
/* EXH --> */ SearchMetadataQueries, SearchTagQueries, SearchTitleQueries /* EXH <-- */
|
||||
{
|
||||
private val configuration = SupportSQLiteOpenHelper.Configuration.builder(context)
|
||||
.name(DbOpenCallback.DATABASE_NAME)
|
||||
.callback(DbOpenCallback())
|
||||
.build()
|
||||
.name(DbOpenCallback.DATABASE_NAME)
|
||||
.callback(DbOpenCallback())
|
||||
.build()
|
||||
|
||||
override val db = DefaultStorIOSQLite.builder()
|
||||
.sqliteOpenHelper(RequerySQLiteOpenHelperFactory().create(configuration))
|
||||
@ -27,6 +37,11 @@ open class DatabaseHelper(context: Context)
|
||||
.addTypeMapping(Category::class.java, CategoryTypeMapping())
|
||||
.addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping())
|
||||
.addTypeMapping(History::class.java, HistoryTypeMapping())
|
||||
// EXH -->
|
||||
.addTypeMapping(SearchMetadata::class.java, SearchMetadataTypeMapping())
|
||||
.addTypeMapping(SearchTag::class.java, SearchTagTypeMapping())
|
||||
.addTypeMapping(SearchTitle::class.java, SearchTitleTypeMapping())
|
||||
// EXH <--
|
||||
.build()
|
||||
|
||||
inline fun inTransaction(block: () -> Unit) = db.inTransaction(block)
|
||||
|
@ -2,10 +2,10 @@ package eu.kanade.tachiyomi.data.database
|
||||
|
||||
import android.arch.persistence.db.SupportSQLiteDatabase
|
||||
import android.arch.persistence.db.SupportSQLiteOpenHelper
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import eu.kanade.tachiyomi.data.database.tables.*
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
import exh.metadata.sql.tables.SearchTagTable
|
||||
import exh.metadata.sql.tables.SearchTitleTable
|
||||
|
||||
class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
|
||||
@ -18,7 +18,7 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
/**
|
||||
* Version of the database.
|
||||
*/
|
||||
const val DATABASE_VERSION = 8
|
||||
const val DATABASE_VERSION = 9 // [EXH]
|
||||
}
|
||||
|
||||
override fun onCreate(db: SupportSQLiteDatabase) = with(db) {
|
||||
@ -28,6 +28,11 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
execSQL(CategoryTable.createTableQuery)
|
||||
execSQL(MangaCategoryTable.createTableQuery)
|
||||
execSQL(HistoryTable.createTableQuery)
|
||||
// EXH -->
|
||||
execSQL(SearchMetadataTable.createTableQuery)
|
||||
execSQL(SearchTagTable.createTableQuery)
|
||||
execSQL(SearchTitleTable.createTableQuery)
|
||||
// EXH <--
|
||||
|
||||
// DB indexes
|
||||
execSQL(MangaTable.createUrlIndexQuery)
|
||||
@ -35,6 +40,14 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
execSQL(ChapterTable.createMangaIdIndexQuery)
|
||||
execSQL(ChapterTable.createUnreadChaptersIndexQuery)
|
||||
execSQL(HistoryTable.createChapterIdIndexQuery)
|
||||
// EXH -->
|
||||
db.execSQL(SearchMetadataTable.createUploaderIndexQuery)
|
||||
db.execSQL(SearchMetadataTable.createIndexedExtraIndexQuery)
|
||||
db.execSQL(SearchTagTable.createMangaIdIndexQuery)
|
||||
db.execSQL(SearchTagTable.createNamespaceNameIndexQuery)
|
||||
db.execSQL(SearchTitleTable.createMangaIdIndexQuery)
|
||||
db.execSQL(SearchTitleTable.createTitleIndexQuery)
|
||||
// EXH <--
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@ -67,6 +80,21 @@ class DbOpenCallback : SupportSQLiteOpenHelper.Callback(DATABASE_VERSION) {
|
||||
db.execSQL(MangaTable.createLibraryIndexQuery)
|
||||
db.execSQL(ChapterTable.createUnreadChaptersIndexQuery)
|
||||
}
|
||||
// EXH -->
|
||||
if (oldVersion < 9) {
|
||||
db.execSQL(SearchMetadataTable.createTableQuery)
|
||||
db.execSQL(SearchTagTable.createTableQuery)
|
||||
db.execSQL(SearchTitleTable.createTableQuery)
|
||||
|
||||
db.execSQL(SearchMetadataTable.createUploaderIndexQuery)
|
||||
db.execSQL(SearchMetadataTable.createIndexedExtraIndexQuery)
|
||||
db.execSQL(SearchTagTable.createMangaIdIndexQuery)
|
||||
db.execSQL(SearchTagTable.createNamespaceNameIndexQuery)
|
||||
db.execSQL(SearchTitleTable.createMangaIdIndexQuery)
|
||||
db.execSQL(SearchTitleTable.createTitleIndexQuery)
|
||||
}
|
||||
// Remember to increment any Tachiyomi database upgrades after this
|
||||
// EXH <--
|
||||
}
|
||||
|
||||
override fun onConfigure(db: SupportSQLiteDatabase) {
|
||||
|
@ -0,0 +1,34 @@
|
||||
package eu.kanade.tachiyomi.data.database.resolvers
|
||||
|
||||
import android.content.ContentValues
|
||||
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
// [EXH]
|
||||
class MangaUrlPutResolver : PutResolver<Manga>() {
|
||||
|
||||
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||
val updateQuery = mapToUpdateQuery(manga)
|
||||
val contentValues = mapToContentValues(manga)
|
||||
|
||||
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||
}
|
||||
|
||||
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_ID} = ?")
|
||||
.whereArgs(manga.id)
|
||||
.build()
|
||||
|
||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||
put(MangaTable.COL_URL, manga.url)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -157,15 +157,7 @@ object PreferenceKeys {
|
||||
|
||||
const val eh_ts_aspNetCookie = "eh_ts_aspNetCookie"
|
||||
|
||||
const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning1"
|
||||
|
||||
const val eh_hl_earlyRefresh = "eh_hl_early_refresh"
|
||||
|
||||
const val eh_hl_refreshFrequency = "eh_hl_refresh_frequency"
|
||||
|
||||
const val eh_hl_lastRefresh = "eh_hl_last_refresh"
|
||||
|
||||
const val eh_hl_lastRealmIndex = "eh_hl_lastRealmIndex"
|
||||
const val eh_showSettingsUploadWarning = "eh_showSettingsUploadWarning2"
|
||||
|
||||
const val eh_expandFilters = "eh_expand_filters"
|
||||
|
||||
@ -182,4 +174,8 @@ object PreferenceKeys {
|
||||
const val eh_preserveReadingPosition = "eh_preserve_reading_position"
|
||||
|
||||
const val eh_incogWebview = "eh_incognito_webview"
|
||||
|
||||
const val eh_autoSolveCaptchas = "eh_autosolve_captchas"
|
||||
|
||||
const val eh_delegateSources = "eh_delegate_sources"
|
||||
}
|
||||
|
@ -189,16 +189,16 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun thumbnailRows() = rxPrefs.getString("ex_thumb_rows", "tr_2")
|
||||
|
||||
fun migrateLibraryAsked2() = rxPrefs.getBoolean("ex_migrate_library2", false)
|
||||
fun migrateLibraryAsked() = rxPrefs.getBoolean("ex_migrate_library3", false)
|
||||
|
||||
fun migrationStatus() = rxPrefs.getInteger("migration_status", MigrationStatus.NOT_INITIALIZED)
|
||||
|
||||
fun hasPerformedURLMigration() = rxPrefs.getBoolean("performed_url_migration", false)
|
||||
|
||||
//EH Cookies
|
||||
fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", null)
|
||||
fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", null)
|
||||
fun igneousVal() = rxPrefs.getString("eh_igneous", null)
|
||||
fun memberIdVal() = rxPrefs.getString("eh_ipb_member_id", "")
|
||||
fun passHashVal() = rxPrefs.getString("eh_ipb_pass_hash", "")
|
||||
fun igneousVal() = rxPrefs.getString("eh_igneous", "")
|
||||
fun eh_ehSettingsProfile() = rxPrefs.getInteger(Keys.eh_ehSettingsProfile, -1)
|
||||
fun eh_exhSettingsProfile() = rxPrefs.getInteger(Keys.eh_exhSettingsProfile, -1)
|
||||
fun eh_settingsKey() = rxPrefs.getString(Keys.eh_settingsKey, "")
|
||||
@ -228,16 +228,6 @@ class PreferencesHelper(val context: Context) {
|
||||
|
||||
fun eh_showSettingsUploadWarning() = rxPrefs.getBoolean(Keys.eh_showSettingsUploadWarning, true)
|
||||
|
||||
// Default is 24h, refresh daily
|
||||
fun eh_hl_earlyRefresh() = rxPrefs.getBoolean(Keys.eh_hl_earlyRefresh, false)
|
||||
|
||||
fun eh_hl_refreshFrequency() = rxPrefs.getString(Keys.eh_hl_refreshFrequency, "24")
|
||||
|
||||
fun eh_hl_lastRefresh() = rxPrefs.getLong(Keys.eh_hl_lastRefresh, 0L)
|
||||
|
||||
fun eh_hl_lastRealmIndex() = rxPrefs.getInteger(Keys.eh_hl_lastRealmIndex, -1)
|
||||
// <-- EH
|
||||
|
||||
fun eh_expandFilters() = rxPrefs.getBoolean(Keys.eh_expandFilters, false)
|
||||
|
||||
fun eh_readerThreads() = rxPrefs.getInteger(Keys.eh_readerThreads, 2)
|
||||
@ -253,4 +243,12 @@ class PreferencesHelper(val context: Context) {
|
||||
fun eh_incogWebview() = rxPrefs.getBoolean(Keys.eh_incogWebview, false)
|
||||
|
||||
fun eh_askCategoryOnLongPress() = rxPrefs.getBoolean(Keys.eh_askCategoryOnLongPress, false)
|
||||
|
||||
fun eh_autoSolveCaptchas() = rxPrefs.getBoolean(Keys.eh_autoSolveCaptchas, false)
|
||||
|
||||
fun eh_delegateSources() = rxPrefs.getBoolean(Keys.eh_delegateSources, true)
|
||||
|
||||
fun eh_lastVersionCode() = rxPrefs.getInteger("eh_last_version_code", 0)
|
||||
|
||||
fun eh_savedSearches() = rxPrefs.getString("eh_saved_searches", "")
|
||||
}
|
||||
|
@ -22,8 +22,12 @@ import exh.EH_SOURCE_ID
|
||||
import exh.EXH_SOURCE_ID
|
||||
import exh.PERV_EDEN_EN_SOURCE_ID
|
||||
import exh.PERV_EDEN_IT_SOURCE_ID
|
||||
import exh.metadata.models.PervEdenLang
|
||||
import exh.metadata.metadata.PervEdenLang
|
||||
import exh.source.DelegatedHttpSource
|
||||
import exh.source.EnhancedHttpSource
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
open class SourceManager(private val context: Context) {
|
||||
|
||||
@ -66,8 +70,17 @@ open class SourceManager(private val context: Context) {
|
||||
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
||||
|
||||
internal fun registerSource(source: Source, overwrite: Boolean = false) {
|
||||
val sourceQName = source::class.qualifiedName
|
||||
val delegate = DELEGATED_SOURCES[sourceQName]
|
||||
val newSource = if(source is HttpSource && delegate != null) {
|
||||
Timber.d("[EXH] Delegating source: %s -> %s!", sourceQName, delegate.newSourceClass.qualifiedName)
|
||||
EnhancedHttpSource(
|
||||
source,
|
||||
delegate.newSourceClass.constructors.find { it.parameters.size == 1 }!!.call(source)
|
||||
)
|
||||
} else source
|
||||
if (overwrite || !sourcesMap.containsKey(source.id)) {
|
||||
sourcesMap[source.id] = source
|
||||
sourcesMap[source.id] = newSource
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,9 +112,8 @@ open class SourceManager(private val context: Context) {
|
||||
exSrcs += PervEden(PERV_EDEN_EN_SOURCE_ID, PervEdenLang.en)
|
||||
exSrcs += PervEden(PERV_EDEN_IT_SOURCE_ID, PervEdenLang.it)
|
||||
exSrcs += NHentai(context)
|
||||
exSrcs += HentaiCafe()
|
||||
exSrcs += Tsumino(context)
|
||||
exSrcs += Hitomi(context)
|
||||
exSrcs += Hitomi()
|
||||
return exSrcs
|
||||
}
|
||||
|
||||
@ -130,4 +142,20 @@ open class SourceManager(private val context: Context) {
|
||||
return Exception(context.getString(R.string.source_not_installed, id.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DELEGATED_SOURCES = listOf(
|
||||
DelegatedSource(
|
||||
"Hentai Cafe",
|
||||
260868874183818481,
|
||||
"eu.kanade.tachiyomi.extension.all.foolslide.HentaiCafe",
|
||||
HentaiCafe::class
|
||||
)
|
||||
).associateBy { it.originalSourcePackageName }
|
||||
|
||||
data class DelegatedSource(val sourceName: String,
|
||||
val sourceId: Long,
|
||||
val originalSourcePackageName: String,
|
||||
val newSourceClass: KClass<out DelegatedHttpSource>)
|
||||
}
|
||||
}
|
||||
|
@ -1,54 +1,102 @@
|
||||
package eu.kanade.tachiyomi.source.online
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.models.GalleryQuery
|
||||
import exh.metadata.models.SearchableGalleryMetadata
|
||||
import exh.util.createUUIDObj
|
||||
import exh.util.defRealm
|
||||
import exh.util.realmTrans
|
||||
import rx.Observable
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.metadata.metadata.base.getFlatMetadataForManga
|
||||
import exh.metadata.metadata.base.insertFlatMetadata
|
||||
import rx.Completable
|
||||
import rx.Single
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* LEWD!
|
||||
*/
|
||||
interface LewdSource<M : SearchableGalleryMetadata, I> : CatalogueSource {
|
||||
fun queryAll(): GalleryQuery<M>
|
||||
interface LewdSource<M : RaisedSearchMetadata, I> : CatalogueSource {
|
||||
val db: DatabaseHelper get() = Injekt.get()
|
||||
|
||||
fun queryFromUrl(url: String): GalleryQuery<M>
|
||||
/**
|
||||
* The class of the metadata used by this source
|
||||
*/
|
||||
val metaClass: KClass<M>
|
||||
|
||||
val metaParser: M.(I) -> Unit
|
||||
/**
|
||||
* Parse the supplied input into the supplied metadata object
|
||||
*/
|
||||
fun parseIntoMetadata(metadata: M, input: I)
|
||||
|
||||
fun parseToManga(query: GalleryQuery<M>, input: I): SManga
|
||||
= realmTrans { realm ->
|
||||
val meta = realm.copyFromRealm(query.query(realm).findFirst()
|
||||
?: realm.createUUIDObj(queryAll().clazz.java))
|
||||
/**
|
||||
* Use reflection to create a new instance of metadata
|
||||
*/
|
||||
private fun newMetaInstance() = metaClass.constructors.find {
|
||||
it.parameters.isEmpty()
|
||||
}?.call() ?: error("Could not find no-args constructor for meta class: ${metaClass.qualifiedName}!")
|
||||
|
||||
metaParser(meta, input)
|
||||
|
||||
realm.copyToRealmOrUpdate(meta)
|
||||
|
||||
SManga.create().apply {
|
||||
meta.copyTo(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun lazyLoadMeta(query: GalleryQuery<M>, parserInput: Observable<I>): Observable<M> {
|
||||
return defRealm { realm ->
|
||||
val possibleOutput = query.query(realm).findFirst()
|
||||
|
||||
if(possibleOutput == null)
|
||||
parserInput.map {
|
||||
realmTrans { realm ->
|
||||
val meta = realm.createUUIDObj(queryAll().clazz.java)
|
||||
|
||||
metaParser(meta, it)
|
||||
|
||||
realm.copyFromRealm(meta)
|
||||
/**
|
||||
* Parses metadata from the input and then copies it into the manga
|
||||
*
|
||||
* Will also save the metadata to the DB if possible
|
||||
*/
|
||||
fun parseToManga(manga: SManga, input: I): Completable {
|
||||
val mangaId = (manga as? Manga)?.id
|
||||
val metaObservable = if(mangaId != null) {
|
||||
db.getFlatMetadataForManga(mangaId).asRxSingle()
|
||||
.map {
|
||||
if(it != null) it.raise(metaClass)
|
||||
else newMetaInstance()
|
||||
}
|
||||
}
|
||||
else
|
||||
Observable.just(realm.copyFromRealm(possibleOutput))
|
||||
} else {
|
||||
Single.just(newMetaInstance())
|
||||
}
|
||||
|
||||
return metaObservable.map {
|
||||
parseIntoMetadata(it, input)
|
||||
it.copyTo(manga)
|
||||
it
|
||||
}.flatMapCompletable {
|
||||
if(mangaId != null) {
|
||||
it.mangaId = mangaId
|
||||
db.insertFlatMetadata(it.flatten())
|
||||
} else Completable.complete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to first get the metadata from the DB. If the metadata is not in the DB, calls the input
|
||||
* producer and parses the metadata from the input
|
||||
*
|
||||
* If the metadata needs to be parsed from the input producer, the resulting parsed metadata will
|
||||
* also be saved to the DB.
|
||||
*/
|
||||
fun getOrLoadMetadata(mangaId: Long?, inputProducer: () -> Single<I>): Single<M> {
|
||||
val metaObservable = if(mangaId != null) {
|
||||
db.getFlatMetadataForManga(mangaId).asRxSingle()
|
||||
.map {
|
||||
it?.raise(metaClass)
|
||||
}
|
||||
} else Single.just(null)
|
||||
|
||||
return metaObservable.flatMap { existingMeta ->
|
||||
if(existingMeta == null) {
|
||||
inputProducer().flatMap { input ->
|
||||
val newMeta = newMetaInstance()
|
||||
parseIntoMetadata(newMeta, input)
|
||||
val newMetaSingle = Single.just(newMeta)
|
||||
if(mangaId != null) {
|
||||
newMeta.mangaId = mangaId
|
||||
db.insertFlatMetadata(newMeta.flatten()).andThen(newMetaSingle)
|
||||
} else newMetaSingle
|
||||
}
|
||||
} else Single.just(existingMeta)
|
||||
}
|
||||
}
|
||||
|
||||
val SManga.id get() = (this as? Manga)?.id
|
||||
val SChapter.mangaId get() = (this as? Chapter)?.manga_id
|
||||
}
|
||||
|
@ -12,8 +12,12 @@ import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.LewdSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.metadata.EX_DATE_FORMAT
|
||||
import exh.metadata.metadata.EHentaiSearchMetadata
|
||||
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.EH_GENRE_NAMESPACE
|
||||
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.TAG_TYPE_LIGHT
|
||||
import exh.metadata.metadata.EHentaiSearchMetadata.Companion.TAG_TYPE_NORMAL
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
|
||||
import exh.metadata.models.ExGalleryMetadata
|
||||
import exh.metadata.models.Tag
|
||||
import exh.metadata.nullIfBlank
|
||||
import exh.metadata.parseHumanReadableByteCount
|
||||
import exh.ui.login.LoginController
|
||||
@ -28,10 +32,12 @@ import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URLEncoder
|
||||
import java.util.*
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
|
||||
class EHentai(override val id: Long,
|
||||
val exh: Boolean,
|
||||
val context: Context) : HttpSource(), LewdSource<ExGalleryMetadata, Response> {
|
||||
val context: Context) : HttpSource(), LewdSource<EHentaiSearchMetadata, Response> {
|
||||
override val metaClass = EHentaiSearchMetadata::class
|
||||
|
||||
val schema: String
|
||||
get() = if(prefs.secureEXH().getOrDefault())
|
||||
@ -60,26 +66,29 @@ class EHentai(override val id: Long,
|
||||
|
||||
fun extendedGenericMangaParse(doc: Document)
|
||||
= with(doc) {
|
||||
//Parse mangas
|
||||
val parsedMangas = select(".gtr0,.gtr1").map {
|
||||
// Parse mangas (supports compact + extended layout)
|
||||
val parsedMangas = select(".itg > tbody > tr").filter {
|
||||
// Do not parse header and ads
|
||||
it.selectFirst("th") == null && it.selectFirst(".itd") == null
|
||||
}.map {
|
||||
val thumbnailElement = it.selectFirst(".gl1e img, .gl2c .glthumb img")
|
||||
val column2 = it.selectFirst(".gl3e, .gl2c")
|
||||
val linkElement = it.selectFirst(".gl3c > a, .gl2e > div > a")
|
||||
|
||||
val favElement = column2.children().find { it.attr("style").startsWith("border-color") }
|
||||
|
||||
ParsedManga(
|
||||
fav = parseFavoritesStyle(it.select(".itd .it3 > .i[id]").first()?.attr("style")),
|
||||
fav = FAVORITES_BORDER_HEX_COLORS.indexOf(
|
||||
favElement?.attr("style")?.substring(14, 17)
|
||||
),
|
||||
manga = Manga.create(id).apply {
|
||||
//Get title
|
||||
it.select(".itd .it5 a").first()?.apply {
|
||||
title = text()
|
||||
url = ExGalleryMetadata.normalizeUrl(attr("href"))
|
||||
}
|
||||
title = thumbnailElement.attr("title")
|
||||
url = EHentaiSearchMetadata.normalizeUrl(linkElement.attr("href"))
|
||||
//Get image
|
||||
it.select(".itd .it2").first()?.apply {
|
||||
children().first()?.let {
|
||||
thumbnail_url = it.attr("src")
|
||||
} ?: let {
|
||||
text().split("~").apply {
|
||||
thumbnail_url = "http://${this[1]}/${this[2]}"
|
||||
}
|
||||
}
|
||||
}
|
||||
thumbnail_url = thumbnailElement.attr("src")
|
||||
|
||||
// TODO Parse genre + uploader + tags
|
||||
})
|
||||
}
|
||||
|
||||
@ -97,14 +106,6 @@ class EHentai(override val id: Long,
|
||||
Pair(parsedMangas, hasNextPage)
|
||||
}
|
||||
|
||||
fun parseFavoritesStyle(style: String?): Int {
|
||||
val offset = style?.substringAfterLast("background-position:0px ")
|
||||
?.removeSuffix("px; cursor:pointer")
|
||||
?.toIntOrNull() ?: return -1
|
||||
|
||||
return (offset + 2)/-19
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a list of galleries
|
||||
*/
|
||||
@ -158,17 +159,17 @@ class EHentai(override val id: Long,
|
||||
override fun popularMangaRequest(page: Int) = if(exh)
|
||||
latestUpdatesRequest(page)
|
||||
else
|
||||
exGet("$baseUrl/toplist.php?tl=15", page)
|
||||
exGet("$baseUrl/toplist.php?tl=15&p=${page - 1}", null) // Custom page logic for toplists
|
||||
|
||||
//Support direct URL importing
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
urlImportFetchSearchManga(query, {
|
||||
urlImportFetchSearchManga(query) {
|
||||
searchMangaRequestObservable(page, query, filters).flatMap {
|
||||
client.newCall(it).asObservableSuccess()
|
||||
} .map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun searchMangaRequestObservable(page: Int, query: String, filters: FilterList): Observable<Request> {
|
||||
val uri = Uri.parse("$baseUrl$QUERY_PREFIX").buildUpon()
|
||||
@ -229,82 +230,112 @@ class EHentai(override val id: Long,
|
||||
}!!
|
||||
|
||||
/**
|
||||
* Parse gallery page to metadata model
|
||||
* 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 mangaDetailsParse(response: Response): SManga {
|
||||
return parseToManga(queryFromUrl(response.request().url().toString()), response)
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.flatMap {
|
||||
parseToManga(manga, it).andThen(Observable.just(manga.apply {
|
||||
initialized = true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
override val metaParser: ExGalleryMetadata.(Response) -> Unit = { response ->
|
||||
with(response.asJsoup()) {
|
||||
url = response.request().url().encodedPath()!!
|
||||
gId = ExGalleryMetadata.galleryId(url!!)
|
||||
gToken = ExGalleryMetadata.galleryToken(url!!)
|
||||
/**
|
||||
* Parse gallery page to metadata model
|
||||
*/
|
||||
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
exh = this@EHentai.exh
|
||||
title = select("#gn").text().nullIfBlank()?.trim()
|
||||
override fun parseIntoMetadata(metadata: EHentaiSearchMetadata, input: Response) {
|
||||
with(metadata) {
|
||||
with(input.asJsoup()) {
|
||||
val url = input.request().url().encodedPath()!!
|
||||
gId = ExGalleryMetadata.galleryId(url)
|
||||
gToken = ExGalleryMetadata.galleryToken(url)
|
||||
|
||||
altTitle = select("#gj").text().nullIfBlank()?.trim()
|
||||
exh = this@EHentai.exh
|
||||
title = select("#gn").text().nullIfBlank()?.trim()
|
||||
|
||||
thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
|
||||
it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
|
||||
}
|
||||
genre = select(".ic").parents().attr("href").nullIfBlank()?.trim()?.substringAfterLast('/')
|
||||
altTitle = select("#gj").text().nullIfBlank()?.trim()
|
||||
|
||||
uploader = select("#gdn").text().nullIfBlank()?.trim()
|
||||
|
||||
//Parse the table
|
||||
select("#gdd tr").forEach {
|
||||
it.select(".gdt1")
|
||||
.text()
|
||||
thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let {
|
||||
it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')'))
|
||||
}
|
||||
genre = select(".cs")
|
||||
.attr("onclick")
|
||||
.nullIfBlank()
|
||||
?.trim()
|
||||
?.let { left ->
|
||||
it.select(".gdt2")
|
||||
.text()
|
||||
.nullIfBlank()
|
||||
?.trim()
|
||||
?.let { right ->
|
||||
ignore {
|
||||
when (left.removeSuffix(":")
|
||||
.toLowerCase()) {
|
||||
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
|
||||
"visible" -> visible = right.nullIfBlank()
|
||||
"language" -> {
|
||||
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
|
||||
translated = right.endsWith(TR_SUFFIX, true)
|
||||
}
|
||||
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
|
||||
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
|
||||
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
?.substringAfterLast('/')
|
||||
?.removeSuffix("'")
|
||||
|
||||
uploader = select("#gdn").text().nullIfBlank()?.trim()
|
||||
|
||||
//Parse the table
|
||||
select("#gdd tr").forEach {
|
||||
val left = it.select(".gdt1").text().nullIfBlank()?.trim()
|
||||
val rightElement = it.selectFirst(".gdt2")
|
||||
val right = rightElement.text().nullIfBlank()?.trim()
|
||||
if(left != null && right != null) {
|
||||
ignore {
|
||||
when (left.removeSuffix(":")
|
||||
.toLowerCase()) {
|
||||
"posted" -> datePosted = EX_DATE_FORMAT.parse(right).time
|
||||
// Example gallery with parent: https://e-hentai.org/g/1390451/7f181c2426/
|
||||
"parent" -> parent = if (!right.equals("None", true)) {
|
||||
rightElement.child(0).attr("href")
|
||||
} else null
|
||||
"visible" -> visible = right.nullIfBlank()
|
||||
"language" -> {
|
||||
language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank()
|
||||
translated = right.endsWith(TR_SUFFIX, true)
|
||||
}
|
||||
"file size" -> size = parseHumanReadableByteCount(right)?.toLong()
|
||||
"length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt()
|
||||
"favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Parse ratings
|
||||
ignore {
|
||||
averageRating = select("#rating_label")
|
||||
.text()
|
||||
.removePrefix("Average:")
|
||||
.trim()
|
||||
.nullIfBlank()
|
||||
?.toDouble()
|
||||
ratingCount = select("#rating_count")
|
||||
.text()
|
||||
.trim()
|
||||
.nullIfBlank()
|
||||
?.toInt()
|
||||
}
|
||||
//Parse ratings
|
||||
ignore {
|
||||
averageRating = select("#rating_label")
|
||||
.text()
|
||||
.removePrefix("Average:")
|
||||
.trim()
|
||||
.nullIfBlank()
|
||||
?.toDouble()
|
||||
ratingCount = select("#rating_count")
|
||||
.text()
|
||||
.trim()
|
||||
.nullIfBlank()
|
||||
?.toInt()
|
||||
}
|
||||
|
||||
//Parse tags
|
||||
tags.clear()
|
||||
select("#taglist tr").forEach {
|
||||
val namespace = it.select(".tc").text().removeSuffix(":")
|
||||
tags.addAll(it.select("div").map {
|
||||
Tag(namespace, it.text().trim(), it.hasClass("gtl"))
|
||||
})
|
||||
//Parse tags
|
||||
tags.clear()
|
||||
select("#taglist tr").forEach {
|
||||
val namespace = it.select(".tc").text().removeSuffix(":")
|
||||
tags.addAll(it.select("div").map { element ->
|
||||
RaisedTag(
|
||||
namespace,
|
||||
element.text().trim(),
|
||||
if(element.hasClass("gtl"))
|
||||
TAG_TYPE_LIGHT
|
||||
else
|
||||
TAG_TYPE_NORMAL
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Add genre as virtual tag
|
||||
genre?.let {
|
||||
tags.add(RaisedTag(EH_GENRE_NAMESPACE, it, TAG_TYPE_VIRTUAL))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -392,8 +423,11 @@ class EHentai(override val id: Long,
|
||||
cookies["hath_perks"] = hathPerksCookie
|
||||
}
|
||||
|
||||
//Session-less list display mode (for users without ExHentai)
|
||||
cookies["sl"] = "dm_0"
|
||||
// Session-less extended display mode (for users without ExHentai)
|
||||
cookies["sl"] = "dm_2"
|
||||
|
||||
// Ignore all content warnings
|
||||
cookies["nw"] = "1"
|
||||
|
||||
return cookies
|
||||
}
|
||||
@ -431,23 +465,26 @@ class EHentai(override val id: Long,
|
||||
ReverseFilter()
|
||||
)
|
||||
|
||||
class GenreOption(name: String, val genreId: String): Filter.CheckBox(name, false), UriFilter {
|
||||
class GenreOption(name: String, val genreId: Int): Filter.CheckBox(name, false)
|
||||
class GenreGroup : Filter.Group<GenreOption>("Genres", listOf(
|
||||
GenreOption("Dōjinshi", 2),
|
||||
GenreOption("Manga", 4),
|
||||
GenreOption("Artist CG", 8),
|
||||
GenreOption("Game CG", 16),
|
||||
GenreOption("Western", 512),
|
||||
GenreOption("Non-H", 256),
|
||||
GenreOption("Image Set", 32),
|
||||
GenreOption("Cosplay", 64),
|
||||
GenreOption("Asian Porn", 128),
|
||||
GenreOption("Misc", 1)
|
||||
)), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
builder.appendQueryParameter("f_" + genreId, if(state) "1" else "0")
|
||||
val bits = state.fold(0) { acc, genre ->
|
||||
if(!genre.state) acc + genre.genreId else acc
|
||||
}
|
||||
builder.appendQueryParameter("f_cats", bits.toString())
|
||||
}
|
||||
}
|
||||
class GenreGroup : UriGroup<GenreOption>("Genres", listOf(
|
||||
GenreOption("Dōjinshi", "doujinshi"),
|
||||
GenreOption("Manga", "manga"),
|
||||
GenreOption("Artist CG", "artistcg"),
|
||||
GenreOption("Game CG", "gamecg"),
|
||||
GenreOption("Western", "western"),
|
||||
GenreOption("Non-H", "non-h"),
|
||||
GenreOption("Image Set", "imageset"),
|
||||
GenreOption("Cosplay", "cosplay"),
|
||||
GenreOption("Asian Porn", "asianporn"),
|
||||
GenreOption("Misc", "misc")
|
||||
))
|
||||
|
||||
class AdvancedOption(name: String, val param: String, defValue: Boolean = false): Filter.CheckBox(name, defValue), UriFilter {
|
||||
override fun addToUri(builder: Uri.Builder) {
|
||||
@ -486,13 +523,23 @@ class EHentai(override val id: Long,
|
||||
else
|
||||
"E-Hentai"
|
||||
|
||||
override fun queryAll() = ExGalleryMetadata.EmptyQuery()
|
||||
override fun queryFromUrl(url: String) = ExGalleryMetadata.UrlQuery(url, exh)
|
||||
|
||||
companion object {
|
||||
val QUERY_PREFIX = "?f_apply=Apply+Filter"
|
||||
val TR_SUFFIX = "TR"
|
||||
val REVERSE_PARAM = "TEH_REVERSE"
|
||||
private const val QUERY_PREFIX = "?f_apply=Apply+Filter"
|
||||
private const val TR_SUFFIX = "TR"
|
||||
private const val REVERSE_PARAM = "TEH_REVERSE"
|
||||
|
||||
private val FAVORITES_BORDER_HEX_COLORS = listOf(
|
||||
"000",
|
||||
"f00",
|
||||
"fa0",
|
||||
"dd0",
|
||||
"080",
|
||||
"9f4",
|
||||
"4bf",
|
||||
"00f",
|
||||
"508",
|
||||
"e8e"
|
||||
)
|
||||
|
||||
fun buildCookies(cookies: Map<String, String>)
|
||||
= cookies.entries.joinToString(separator = "; ") {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonNull
|
||||
import com.google.gson.JsonObject
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.BuildConfig
|
||||
import eu.kanade.tachiyomi.R
|
||||
@ -14,55 +13,93 @@ 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.util.asJsoup
|
||||
import exh.NHENTAI_SOURCE_ID
|
||||
import exh.metadata.metadata.NHentaiSearchMetadata
|
||||
import exh.metadata.metadata.NHentaiSearchMetadata.Companion.TAG_TYPE_DEFAULT
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.metadata.models.NHentaiMetadata
|
||||
import exh.metadata.models.PageImageType
|
||||
import exh.metadata.models.Tag
|
||||
import exh.util.*
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* NHentai source
|
||||
*/
|
||||
|
||||
class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, JsonObject> {
|
||||
class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiSearchMetadata, Response> {
|
||||
override val metaClass = NHentaiSearchMetadata::class
|
||||
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
//TODO There is currently no way to get the most popular mangas
|
||||
//TODO Instead, we delegate this to the latest updates thing to avoid confusing users with an empty screen
|
||||
return fetchLatestUpdates(page)
|
||||
}
|
||||
|
||||
override fun popularMangaRequest(page: Int): Request {
|
||||
TODO("Currently unavailable!")
|
||||
}
|
||||
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
|
||||
|
||||
override fun popularMangaParse(response: Response): MangasPage {
|
||||
TODO("Currently unavailable!")
|
||||
}
|
||||
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
//Support direct URL importing
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
urlImportFetchSearchManga(query, {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
})
|
||||
urlImportFetchSearchManga(query) {
|
||||
searchMangaRequestObservable(page, query, filters).flatMap {
|
||||
client.newCall(it).asObservableSuccess()
|
||||
} .map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchMangaRequestObservable(page: Int, query: String, filters: FilterList): Observable<Request> {
|
||||
val uri = if(query.isNotBlank()) {
|
||||
Uri.parse("$baseUrl/search/").buildUpon().apply {
|
||||
appendQueryParameter("q", query)
|
||||
}
|
||||
} else {
|
||||
Uri.parse(baseUrl).buildUpon()
|
||||
}
|
||||
val sortFilter = filters.filterIsInstance<SortFilter>().firstOrNull()?.state
|
||||
?: defaultSortFilterSelection()
|
||||
|
||||
if(sortFilter.index == 1) {
|
||||
if(query.isBlank()) error("You must specify a search query if you wish to sort by popularity!")
|
||||
uri.appendQueryParameter("sort", "popular")
|
||||
}
|
||||
|
||||
if(sortFilter.ascending) {
|
||||
return client.newCall(nhGet(uri.toString()))
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
val doc = it.asJsoup()
|
||||
|
||||
val lastPage = doc.selectFirst(".last")
|
||||
?.attr("href")
|
||||
?.substringAfterLast('=')
|
||||
?.toIntOrNull() ?: 1
|
||||
|
||||
val thisPage = lastPage - (page - 1)
|
||||
|
||||
uri.appendQueryParameter(REVERSE_PARAM, (thisPage > 1).toString())
|
||||
uri.appendQueryParameter("page", thisPage.toString())
|
||||
|
||||
nhGet(uri.toString(), page)
|
||||
}
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
//Currently we have no filters
|
||||
//TODO Filter builder
|
||||
val uri = Uri.parse("$baseUrl/api/galleries/search").buildUpon()
|
||||
uri.appendQueryParameter("query", query)
|
||||
uri.appendQueryParameter("page", page.toString())
|
||||
return nhGet(uri.toString(), page)
|
||||
|
||||
return Observable.just(nhGet(uri.toString(), page))
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList)
|
||||
= throw UnsupportedOperationException()
|
||||
|
||||
override fun searchMangaParse(response: Response)
|
||||
= parseResultPage(response)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val uri = Uri.parse("$baseUrl/api/galleries/all").buildUpon()
|
||||
val uri = Uri.parse(baseUrl).buildUpon()
|
||||
uri.appendQueryParameter("page", page.toString())
|
||||
return nhGet(uri.toString(), page)
|
||||
}
|
||||
@ -70,124 +107,122 @@ class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, Json
|
||||
override fun latestUpdatesParse(response: Response)
|
||||
= parseResultPage(response)
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val obj = jsonParser.parse(response.body()!!.string()).asJsonObject
|
||||
return parseToManga(NHentaiMetadata.Query(obj["id"].long), obj)
|
||||
}
|
||||
override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException()
|
||||
|
||||
//Used so we can use a different URL for fetching manga details and opening the details in the browser
|
||||
/**
|
||||
* 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(urlToDetailsRequest(manga.url))
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
mangaDetailsParse(response).apply { initialized = true }
|
||||
.flatMap {
|
||||
parseToManga(manga, it).andThen(Observable.just(manga.apply {
|
||||
initialized = true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsRequest(manga: SManga)
|
||||
= nhGet(manga.url)
|
||||
= nhGet(baseUrl + manga.url)
|
||||
|
||||
fun urlToDetailsRequest(url: String)
|
||||
= nhGet(baseUrl + "/api/gallery/" + url.split("/").last { it.isNotBlank() })
|
||||
|
||||
fun parseResultPage(response: Response): MangasPage {
|
||||
val res = jsonParser.parse(response.body()!!.string()).asJsonObject
|
||||
val doc = response.asJsoup()
|
||||
|
||||
val error = res.get("error")
|
||||
if(error == null) {
|
||||
val results = res.getAsJsonArray("result")?.map {
|
||||
val obj = it.asJsonObject
|
||||
parseToManga(NHentaiMetadata.Query(obj["id"].long), obj)
|
||||
// TODO Parse lang + tags
|
||||
|
||||
val mangas = doc.select(".gallery > a").map {
|
||||
SManga.create().apply {
|
||||
url = it.attr("href")
|
||||
|
||||
title = it.selectFirst(".caption").text()
|
||||
|
||||
// last() is a hack to ignore the lazy-loader placeholder image on the front page
|
||||
thumbnail_url = it.select("img").last().attr("src")
|
||||
// In some pages, the thumbnail url does not include the protocol
|
||||
if(!thumbnail_url!!.startsWith("https:")) thumbnail_url = "https:$thumbnail_url"
|
||||
}
|
||||
val numPages = res.get("num_pages")?.int
|
||||
if(results != null && numPages != null)
|
||||
return MangasPage(results, numPages > response.request().tag() as Int)
|
||||
}
|
||||
|
||||
val hasNextPage = if(!response.request().url().queryParameterNames().contains(REVERSE_PARAM)) {
|
||||
doc.selectFirst(".next") != null
|
||||
} else {
|
||||
Timber.w("An error occurred while performing the search: $error")
|
||||
response.request().url().queryParameter(REVERSE_PARAM)!!.toBoolean()
|
||||
}
|
||||
return MangasPage(emptyList(), false)
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
override val metaParser: NHentaiMetadata.(JsonObject) -> Unit = { obj ->
|
||||
nhId = obj["id"].asLong
|
||||
override fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
|
||||
val json = GALLERY_JSON_REGEX.find(input.body()!!.string())!!.groupValues[1]
|
||||
val obj = jsonParser.parse(json).asJsonObject
|
||||
|
||||
uploadDate = obj["upload_date"].nullLong
|
||||
with(metadata) {
|
||||
nhId = obj["id"].asLong
|
||||
|
||||
favoritesCount = obj["num_favorites"].nullLong
|
||||
uploadDate = obj["upload_date"].nullLong
|
||||
|
||||
mediaId = obj["media_id"].nullString
|
||||
favoritesCount = obj["num_favorites"].nullLong
|
||||
|
||||
obj["title"].nullObj?.let { it ->
|
||||
japaneseTitle = it["japanese"].nullString
|
||||
shortTitle = it["pretty"].nullString
|
||||
englishTitle = it["english"].nullString
|
||||
}
|
||||
mediaId = obj["media_id"].nullString
|
||||
|
||||
obj["images"].nullObj?.let {
|
||||
coverImageType = it["cover"]?.get("t").nullString
|
||||
it["pages"].nullArray?.mapNotNull {
|
||||
it?.asJsonObject?.get("t").nullString
|
||||
}?.map {
|
||||
PageImageType(it)
|
||||
}?.let {
|
||||
pageImageTypes.clear()
|
||||
pageImageTypes.addAll(it)
|
||||
obj["title"].nullObj?.let { title ->
|
||||
japaneseTitle = title["japanese"].nullString
|
||||
shortTitle = title["pretty"].nullString
|
||||
englishTitle = title["english"].nullString
|
||||
}
|
||||
thumbnailImageType = it["thumbnail"]?.get("t").nullString
|
||||
}
|
||||
|
||||
scanlator = obj["scanlator"].nullString
|
||||
|
||||
obj["tags"]?.asJsonArray?.map {
|
||||
val asObj = it.asJsonObject
|
||||
Pair(asObj["type"].nullString, asObj["name"].nullString)
|
||||
}?.apply {
|
||||
tags.clear()
|
||||
}?.forEach {
|
||||
if(it.first != null && it.second != null)
|
||||
tags.add(Tag(it.first!!, it.second!!, false))
|
||||
}
|
||||
}
|
||||
|
||||
fun lazyLoadMetadata(url: String) =
|
||||
defRealm { realm ->
|
||||
val meta = NHentaiMetadata.UrlQuery(url).query(realm).findFirst()
|
||||
if(meta == null) {
|
||||
client.newCall(urlToDetailsRequest(url))
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
realmTrans { realm ->
|
||||
realm.copyFromRealm(realm.createUUIDObj(queryAll().clazz.java).apply {
|
||||
metaParser(this,
|
||||
jsonParser.parse(it.body()!!.string()).asJsonObject)
|
||||
})
|
||||
}
|
||||
}
|
||||
.first()
|
||||
} else {
|
||||
Observable.just(realm.copyFromRealm(meta))
|
||||
obj["images"].nullObj?.let {
|
||||
coverImageType = it["cover"]?.get("t").nullString
|
||||
it["pages"].nullArray?.mapNotNull {
|
||||
it?.asJsonObject?.get("t").nullString
|
||||
}?.let {
|
||||
pageImageTypes = it
|
||||
}
|
||||
thumbnailImageType = it["thumbnail"]?.get("t").nullString
|
||||
}
|
||||
|
||||
scanlator = obj["scanlator"].nullString
|
||||
|
||||
obj["tags"]?.asJsonArray?.map {
|
||||
val asObj = it.asJsonObject
|
||||
Pair(asObj["type"].nullString, asObj["name"].nullString)
|
||||
}?.apply {
|
||||
tags.clear()
|
||||
}?.forEach {
|
||||
if (it.first != null && it.second != null)
|
||||
tags.add(RaisedTag(it.first!!, it.second!!, TAG_TYPE_DEFAULT))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getOrLoadMetadata(mangaId: Long?, nhId: Long) = getOrLoadMetadata(mangaId) {
|
||||
client.newCall(nhGet(baseUrl + NHentaiSearchMetadata.nhIdToPath(nhId)))
|
||||
.asObservableSuccess()
|
||||
.toSingle()
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga)
|
||||
= lazyLoadMetadata(manga.url).map {
|
||||
listOf(SChapter.create().apply {
|
||||
url = manga.url
|
||||
name = "Chapter"
|
||||
date_upload = ((it.uploadDate ?: 0) * 1000)
|
||||
chapter_number = 1f
|
||||
})
|
||||
}!!
|
||||
= Observable.just(listOf(SChapter.create().apply {
|
||||
url = manga.url
|
||||
name = "Chapter"
|
||||
chapter_number = 1f
|
||||
}))
|
||||
|
||||
override fun fetchPageList(chapter: SChapter)
|
||||
= lazyLoadMetadata(chapter.url).map { metadata ->
|
||||
= getOrLoadMetadata(chapter.mangaId, NHentaiSearchMetadata.nhUrlToId(chapter.url)).map { metadata ->
|
||||
if(metadata.mediaId == null) emptyList()
|
||||
else
|
||||
metadata.pageImageTypes.mapIndexed { index, s ->
|
||||
val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s.type!!)
|
||||
val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s)
|
||||
Page(index, imageUrl!!, imageUrl)
|
||||
}
|
||||
}!!
|
||||
}.toObservable()
|
||||
|
||||
override fun fetchImageUrl(page: Page) = Observable.just(page.imageUrl!!)!!
|
||||
|
||||
@ -207,6 +242,14 @@ class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, Json
|
||||
throw NotImplementedError("Unused method called!")
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(SortFilter())
|
||||
|
||||
class SortFilter : Filter.Sort(
|
||||
"Sort",
|
||||
arrayOf("Date", "Popular"),
|
||||
defaultSortFilterSelection()
|
||||
)
|
||||
|
||||
val appName by lazy {
|
||||
context.getString(R.string.app_name)!!
|
||||
}
|
||||
@ -226,14 +269,16 @@ class NHentai(context: Context) : HttpSource(), LewdSource<NHentaiMetadata, Json
|
||||
|
||||
override val name = "nhentai"
|
||||
|
||||
override val baseUrl = NHentaiMetadata.BASE_URL
|
||||
override val baseUrl = NHentaiSearchMetadata.BASE_URL
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun queryAll() = NHentaiMetadata.EmptyQuery()
|
||||
override fun queryFromUrl(url: String) = NHentaiMetadata.UrlQuery(url)
|
||||
|
||||
companion object {
|
||||
private val GALLERY_JSON_REGEX = Regex("new N.gallery\\((.*)\\);")
|
||||
private const val REVERSE_PARAM = "TEH_REVERSE"
|
||||
|
||||
private fun defaultSortFilterSelection() = Filter.Sort.Selection(0, false)
|
||||
|
||||
val jsonParser by lazy {
|
||||
JsonParser()
|
||||
}
|
||||
|
@ -2,16 +2,18 @@ package eu.kanade.tachiyomi.source.online.all
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.LewdSource
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.metadata.EMULATED_TAG_NAMESPACE
|
||||
import exh.metadata.metadata.PervEdenLang
|
||||
import exh.metadata.metadata.PervEdenSearchMetadata
|
||||
import exh.metadata.metadata.PervEdenSearchMetadata.Companion.TAG_TYPE_DEFAULT
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.metadata.models.PervEdenGalleryMetadata
|
||||
import exh.metadata.models.PervEdenLang
|
||||
import exh.metadata.models.PervEdenTitle
|
||||
import exh.metadata.models.Tag
|
||||
import exh.util.UriFilter
|
||||
import exh.util.UriGroup
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
@ -20,11 +22,17 @@ import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.nodes.TextNode
|
||||
import rx.Observable
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
// TODO Transform into delegated source
|
||||
class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSource(),
|
||||
LewdSource<PervEdenGalleryMetadata, Document> {
|
||||
LewdSource<PervEdenSearchMetadata, Document> {
|
||||
/**
|
||||
* The class of the metadata used by this source
|
||||
*/
|
||||
override val metaClass = PervEdenSearchMetadata::class
|
||||
|
||||
override val supportsLatest = true
|
||||
override val name = "Perv Eden"
|
||||
@ -48,9 +56,9 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
|
||||
|
||||
//Support direct URL importing
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
urlImportFetchSearchManga(query, {
|
||||
urlImportFetchSearchManga(query) {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
})
|
||||
}
|
||||
|
||||
override fun searchMangaSelector() = "#mangaList > tbody > tr"
|
||||
|
||||
@ -79,7 +87,7 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
|
||||
val titleElement = header.child(0)
|
||||
manga.url = titleElement.attr("href")
|
||||
manga.title = titleElement.text().trim()
|
||||
manga.thumbnail_url = "http:" + titleElement.getElementsByClass("mangaImage").first().attr("tmpsrc")
|
||||
manga.thumbnail_url = "https:" + header.parent().selectFirst(".mangaImage img").attr("tmpsrc")
|
||||
return manga
|
||||
}
|
||||
|
||||
@ -107,67 +115,90 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
|
||||
throw NotImplementedError("Unused method called!")
|
||||
}
|
||||
|
||||
override val metaParser: PervEdenGalleryMetadata.(Document) -> Unit = { document ->
|
||||
url = Uri.parse(document.location()).path
|
||||
/**
|
||||
* 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.apply {
|
||||
initialized = true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pvId = PervEdenGalleryMetadata.pvIdFromUrl(url!!)
|
||||
/**
|
||||
* Parse the supplied input into the supplied metadata object
|
||||
*/
|
||||
override fun parseIntoMetadata(metadata: PervEdenSearchMetadata, input: Document) {
|
||||
with(metadata) {
|
||||
url = Uri.parse(input.location()).path
|
||||
|
||||
lang = this@PervEden.lang
|
||||
pvId = PervEdenGalleryMetadata.pvIdFromUrl(url!!)
|
||||
|
||||
title = document.getElementsByClass("manga-title").first()?.text()
|
||||
lang = this@PervEden.lang
|
||||
|
||||
thumbnailUrl = "http:" + document.getElementsByClass("mangaImage2").first()?.child(0)?.attr("src")
|
||||
title = input.getElementsByClass("manga-title").first()?.text()
|
||||
|
||||
val rightBoxElement = document.select(".rightBox:not(.info)").first()
|
||||
thumbnailUrl = "http:" + input.getElementsByClass("mangaImage2").first()?.child(0)?.attr("src")
|
||||
|
||||
altTitles.clear()
|
||||
tags.clear()
|
||||
var inStatus: String? = null
|
||||
rightBoxElement.childNodes().forEach {
|
||||
if(it is Element && it.tagName().toLowerCase() == "h4") {
|
||||
inStatus = it.text().trim()
|
||||
} else {
|
||||
when(inStatus) {
|
||||
"Alternative name(s)" -> {
|
||||
if(it is TextNode) {
|
||||
val text = it.text().trim()
|
||||
if(!text.isBlank())
|
||||
altTitles.add(PervEdenTitle(this, text))
|
||||
val rightBoxElement = input.select(".rightBox:not(.info)").first()
|
||||
|
||||
val newAltTitles = mutableListOf<String>()
|
||||
tags.clear()
|
||||
var inStatus: String? = null
|
||||
rightBoxElement.childNodes().forEach {
|
||||
if(it is Element && it.tagName().toLowerCase() == "h4") {
|
||||
inStatus = it.text().trim()
|
||||
} else {
|
||||
when(inStatus) {
|
||||
"Alternative name(s)" -> {
|
||||
if(it is TextNode) {
|
||||
val text = it.text().trim()
|
||||
if(!text.isBlank())
|
||||
newAltTitles += text
|
||||
}
|
||||
}
|
||||
}
|
||||
"Artist" -> {
|
||||
if(it is Element && it.tagName() == "a") {
|
||||
artist = it.text()
|
||||
tags.add(Tag("artist", it.text().toLowerCase(), false))
|
||||
"Artist" -> {
|
||||
if(it is Element && it.tagName() == "a") {
|
||||
artist = it.text()
|
||||
tags += RaisedTag("artist", it.text().toLowerCase(), TAG_TYPE_VIRTUAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
"Genres" -> {
|
||||
if(it is Element && it.tagName() == "a")
|
||||
tags.add(Tag(EMULATED_TAG_NAMESPACE, it.text().toLowerCase(), false))
|
||||
}
|
||||
"Type" -> {
|
||||
if(it is TextNode) {
|
||||
val text = it.text().trim()
|
||||
if(!text.isBlank())
|
||||
type = text
|
||||
"Genres" -> {
|
||||
if(it is Element && it.tagName() == "a")
|
||||
tags += RaisedTag(null, it.text().toLowerCase(), TAG_TYPE_DEFAULT)
|
||||
}
|
||||
}
|
||||
"Status" -> {
|
||||
if(it is TextNode) {
|
||||
val text = it.text().trim()
|
||||
if(!text.isBlank())
|
||||
status = text
|
||||
"Type" -> {
|
||||
if(it is TextNode) {
|
||||
val text = it.text().trim()
|
||||
if(!text.isBlank())
|
||||
type = text
|
||||
}
|
||||
}
|
||||
"Status" -> {
|
||||
if(it is TextNode) {
|
||||
val text = it.text().trim()
|
||||
if(!text.isBlank())
|
||||
status = text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rating = document.getElementById("rating-score")?.attr("value")?.toFloat()
|
||||
altTitles = newAltTitles
|
||||
|
||||
rating = input.getElementById("rating-score")?.attr("value")?.toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga
|
||||
= parseToManga(queryFromUrl(document.location()), document)
|
||||
= throw UnsupportedOperationException()
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val num = when (lang) {
|
||||
@ -206,9 +237,6 @@ class PervEden(override val id: Long, val pvLang: PervEdenLang) : ParsedHttpSour
|
||||
override fun imageUrlParse(document: Document)
|
||||
= "http:" + document.getElementById("mainImg").attr("src")!!
|
||||
|
||||
override fun queryAll() = PervEdenGalleryMetadata.EmptyQuery()
|
||||
override fun queryFromUrl(url: String) = PervEdenGalleryMetadata.UrlQuery(url, PervEdenLang.source(id))
|
||||
|
||||
override fun getFilterList() = FilterList (
|
||||
AuthorFilter(),
|
||||
ArtistFilter(),
|
||||
|
@ -1,204 +1,91 @@
|
||||
package eu.kanade.tachiyomi.source.online.english
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.source.online.LewdSource
|
||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.HENTAI_CAFE_SOURCE_ID
|
||||
import exh.metadata.EMULATED_TAG_NAMESPACE
|
||||
import exh.metadata.models.HentaiCafeMetadata
|
||||
import exh.metadata.models.HentaiCafeMetadata.Companion.BASE_URL
|
||||
import exh.metadata.models.Tag
|
||||
import exh.metadata.metadata.HentaiCafeSearchMetadata
|
||||
import exh.metadata.metadata.HentaiCafeSearchMetadata.Companion.TAG_TYPE_DEFAULT
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.source.DelegatedHttpSource
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
import okhttp3.Request
|
||||
import okhttp3.HttpUrl
|
||||
import org.jsoup.nodes.Document
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
|
||||
class HentaiCafe : ParsedHttpSource(), LewdSource<HentaiCafeMetadata, Document> {
|
||||
override val id = HENTAI_CAFE_SOURCE_ID
|
||||
|
||||
class HentaiCafe(delegate: HttpSource) : DelegatedHttpSource(delegate),
|
||||
LewdSource<HentaiCafeSearchMetadata, Document> {
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang = "en"
|
||||
/**
|
||||
* The class of the metadata used by this source
|
||||
*/
|
||||
override val metaClass = HentaiCafeSearchMetadata::class
|
||||
|
||||
override val supportsLatest = true
|
||||
|
||||
override fun queryAll() = HentaiCafeMetadata.EmptyQuery()
|
||||
override fun queryFromUrl(url: String) = HentaiCafeMetadata.UrlQuery(url)
|
||||
|
||||
override val name = "Hentai Cafe"
|
||||
override val baseUrl = "https://hentai.cafe"
|
||||
|
||||
// Defer popular manga -> latest updates
|
||||
override fun popularMangaSelector() = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun popularMangaFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun popularMangaNextPageSelector() = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun fetchPopularManga(page: Int) = fetchLatestUpdates(page)
|
||||
|
||||
//Support direct URL importing
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
urlImportFetchSearchManga(query, {
|
||||
urlImportFetchSearchManga(query) {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
})
|
||||
override fun searchMangaSelector() = "article.post:not(#post-0)"
|
||||
override fun searchMangaFromElement(element: Element): SManga {
|
||||
val thumb = element.select(".entry-thumb > img")
|
||||
val title = element.select(".entry-title > a")
|
||||
|
||||
return SManga.create().apply {
|
||||
setUrlWithoutDomain(title.attr("href"))
|
||||
this.title = title.text()
|
||||
|
||||
thumbnail_url = thumb.attr("src")
|
||||
}
|
||||
}
|
||||
override fun searchMangaNextPageSelector() = ".x-pagination > ul > li:last-child > a.prev-next"
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val url = if(query.isNotBlank()) {
|
||||
//Filter by query
|
||||
"$baseUrl/page/$page/?s=${Uri.encode(query)}"
|
||||
} else if(filters.filterIsInstance<ShowBooksOnlyFilter>().any { it.state }) {
|
||||
//Filter by book
|
||||
"$baseUrl/category/book/page/$page/"
|
||||
} else {
|
||||
//Filter by tag
|
||||
val tagFilter = filters.filterIsInstance<TagFilter>().first()
|
||||
|
||||
if(tagFilter.state == 0) throw IllegalArgumentException("No filters active, no query active! What to filter?")
|
||||
|
||||
val tag = tagFilter.values[tagFilter.state]
|
||||
"$baseUrl/tag/${tag.id}/page/$page/"
|
||||
}
|
||||
|
||||
return GET(url)
|
||||
}
|
||||
|
||||
override fun latestUpdatesSelector() = searchMangaSelector()
|
||||
override fun latestUpdatesFromElement(element: Element) = searchMangaFromElement(element)
|
||||
override fun latestUpdatesNextPageSelector() = searchMangaNextPageSelector()
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$BASE_URL/page/$page/")
|
||||
|
||||
override fun mangaDetailsParse(document: Document): SManga {
|
||||
return parseToManga(queryFromUrl(document.location()), document)
|
||||
}
|
||||
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return lazyLoadMeta(queryFromUrl(manga.url),
|
||||
client.newCall(mangaDetailsRequest(manga)).asObservableSuccess().map { it.asJsoup() }
|
||||
).map {
|
||||
listOf(SChapter.create().apply {
|
||||
url = "/manga/read/${it.readerId}/en/0/1/"
|
||||
|
||||
name = "Chapter"
|
||||
|
||||
chapter_number = 1f
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun pageListParse(document: Document): List<Page> {
|
||||
val pageItems = document.select(".dropdown > li > a")
|
||||
|
||||
return pageItems.mapIndexed { index, element ->
|
||||
Page(index, element.attr("href"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(document: Document)
|
||||
= document.select("#page img").attr("src")
|
||||
|
||||
override val metaParser: HentaiCafeMetadata.(Document) -> Unit = {
|
||||
val content = it.getElementsByClass("content")
|
||||
val eTitle = content.select("h3")
|
||||
|
||||
url = Uri.decode(it.location())
|
||||
title = eTitle.text()
|
||||
|
||||
thumbnailUrl = content.select("img").attr("src")
|
||||
|
||||
tags.clear()
|
||||
val eDetails = content.select("p > a[rel=tag]")
|
||||
eDetails.forEach {
|
||||
val href = it.attr("href")
|
||||
val parsed = Uri.parse(href)
|
||||
val firstPath = parsed.pathSegments.first()
|
||||
|
||||
when(firstPath) {
|
||||
"tag" -> tags.add(Tag(EMULATED_TAG_NAMESPACE, it.text(), false))
|
||||
"artist" -> {
|
||||
artist = it.text()
|
||||
tags.add(Tag("artist", it.text(), false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.flatMap {
|
||||
parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga.apply {
|
||||
initialized = true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the supplied input into the supplied metadata object
|
||||
*/
|
||||
override fun parseIntoMetadata(metadata: HentaiCafeSearchMetadata, input: Document) {
|
||||
with(metadata) {
|
||||
url = input.location()
|
||||
title = input.select(".entry-title").text()
|
||||
val contentElement = input.select(".entry-content").first()
|
||||
thumbnailUrl = contentElement.child(0).child(0).attr("src")
|
||||
|
||||
fun filterableTagsOfType(type: String) = contentElement.select("a")
|
||||
.filter { "$baseUrl/$type/" in it.attr("href") }
|
||||
.map { it.text() }
|
||||
|
||||
tags.clear()
|
||||
tags += filterableTagsOfType("tag").map {
|
||||
RaisedTag(null, it, TAG_TYPE_DEFAULT)
|
||||
}
|
||||
|
||||
val artists = filterableTagsOfType("artist")
|
||||
|
||||
artist = artists.joinToString()
|
||||
tags += artists.map {
|
||||
RaisedTag("artist", it, TAG_TYPE_VIRTUAL)
|
||||
}
|
||||
|
||||
readerId = HttpUrl.parse(input.select("[title=Read]").attr("href"))!!.pathSegments()[2]
|
||||
}
|
||||
|
||||
readerId = Uri.parse(content.select("a[title=Read]").attr("href")).pathSegments[2]
|
||||
}
|
||||
|
||||
override fun getFilterList() = FilterList(
|
||||
TagFilter(),
|
||||
ShowBooksOnlyFilter()
|
||||
)
|
||||
|
||||
class ShowBooksOnlyFilter : Filter.CheckBox("Show books only")
|
||||
|
||||
class TagFilter : Filter.Select<HCTag>("Filter by tag", listOf(
|
||||
"???" to "None",
|
||||
|
||||
"ahegao" to "Ahegao",
|
||||
"anal" to "Anal",
|
||||
"big-ass" to "Big ass",
|
||||
"big-breast" to "Big Breast",
|
||||
"bondage" to "Bondage",
|
||||
"cheating" to "Cheating",
|
||||
"chubby" to "Chubby",
|
||||
"condom" to "Condom",
|
||||
"cosplay" to "Cosplay",
|
||||
"cunnilingus" to "Cunnilingus",
|
||||
"dark-skin" to "Dark skin",
|
||||
"defloration" to "Defloration",
|
||||
"exhibitionism" to "Exhibitionism",
|
||||
"fellatio" to "Fellatio",
|
||||
"femdom" to "Femdom",
|
||||
"flat-chest" to "Flat chest",
|
||||
"full-color" to "Full color",
|
||||
"glasses" to "Glasses",
|
||||
"group" to "Group",
|
||||
"hairy" to "Hairy",
|
||||
"handjob" to "Handjob",
|
||||
"harem" to "Harem",
|
||||
"housewife" to "Housewife",
|
||||
"incest" to "Incest",
|
||||
"large-breast" to "Large Breast",
|
||||
"lingerie" to "Lingerie",
|
||||
"loli" to "Loli",
|
||||
"masturbation" to "Masturbation",
|
||||
"nakadashi" to "Nakadashi",
|
||||
"netorare" to "Netorare",
|
||||
"office-lady" to "Office Lady",
|
||||
"osananajimi" to "Osananajimi",
|
||||
"paizuri" to "Paizuri",
|
||||
"pettanko" to "Pettanko",
|
||||
"rape" to "Rape",
|
||||
"schoolgirl" to "Schoolgirl",
|
||||
"sex-toys" to "Sex Toys",
|
||||
"shota" to "Shota",
|
||||
"stocking" to "Stocking",
|
||||
"swimsuit" to "Swimsuit",
|
||||
"teacher" to "Teacher",
|
||||
"tsundere" to "Tsundere",
|
||||
"uncensored" to "uncensored",
|
||||
"x-ray" to "X-ray"
|
||||
).map { HCTag(it.first, it.second) }.toTypedArray()
|
||||
)
|
||||
|
||||
class HCTag(val id: String, val displayName: String) {
|
||||
override fun toString() = displayName
|
||||
}
|
||||
override fun fetchChapterList(manga: SManga) = getOrLoadMetadata(manga.id) {
|
||||
client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { it.asJsoup() }
|
||||
.toSingle()
|
||||
}.map {
|
||||
listOf(
|
||||
SChapter.create().apply {
|
||||
setUrlWithoutDomain("/manga/read/${it.readerId}/en/0/1/")
|
||||
name = "Chapter"
|
||||
chapter_number = 0.0f
|
||||
}
|
||||
)
|
||||
}.toObservable()
|
||||
}
|
||||
|
@ -18,10 +18,11 @@ import eu.kanade.tachiyomi.util.toast
|
||||
import exh.TSUMINO_SOURCE_ID
|
||||
import exh.ui.captcha.CaptchaCompletionVerifier
|
||||
import exh.ui.captcha.SolveCaptchaActivity
|
||||
import exh.metadata.EMULATED_TAG_NAMESPACE
|
||||
import exh.metadata.models.Tag
|
||||
import exh.metadata.models.TsuminoMetadata
|
||||
import exh.metadata.models.TsuminoMetadata.Companion.BASE_URL
|
||||
import exh.metadata.metadata.TsuminoSearchMetadata
|
||||
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.BASE_URL
|
||||
import exh.metadata.metadata.TsuminoSearchMetadata.Companion.TAG_TYPE_DEFAULT
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata.Companion.TAG_TYPE_VIRTUAL
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
import okhttp3.*
|
||||
import org.jsoup.nodes.Document
|
||||
@ -32,7 +33,9 @@ import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<TsuminoMetadata, Document>, CaptchaCompletionVerifier {
|
||||
class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<TsuminoSearchMetadata, Document>, CaptchaCompletionVerifier {
|
||||
override val metaClass = TsuminoSearchMetadata::class
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
override val id = TSUMINO_SOURCE_ID
|
||||
@ -41,77 +44,76 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
override val supportsLatest = true
|
||||
override val name = "Tsumino"
|
||||
|
||||
override fun queryAll() = TsuminoMetadata.EmptyQuery()
|
||||
|
||||
override fun queryFromUrl(url: String) = TsuminoMetadata.UrlQuery(url)
|
||||
|
||||
override val baseUrl = BASE_URL
|
||||
|
||||
override val metaParser: TsuminoMetadata.(Document) -> Unit = {
|
||||
url = it.location()
|
||||
tags.clear()
|
||||
|
||||
it.getElementById("Title")?.text()?.let {
|
||||
title = it.trim()
|
||||
}
|
||||
|
||||
it.getElementById("Artist")?.children()?.first()?.text()?.trim()?.let {
|
||||
tags.add(Tag("artist", it, false))
|
||||
artist = it
|
||||
}
|
||||
|
||||
it.getElementById("Uploader")?.children()?.first()?.text()?.trim()?.let {
|
||||
tags.add(Tag("uploader", it, false))
|
||||
uploader = it
|
||||
}
|
||||
|
||||
it.getElementById("Uploaded")?.text()?.let {
|
||||
uploadDate = TM_DATE_FORMAT.parse(it.trim()).time
|
||||
}
|
||||
|
||||
it.getElementById("Pages")?.text()?.let {
|
||||
length = it.trim().toIntOrNull()
|
||||
}
|
||||
|
||||
it.getElementById("Rating")?.text()?.let {
|
||||
ratingString = it.trim()
|
||||
}
|
||||
|
||||
it.getElementById("Category")?.children()?.first()?.text()?.let {
|
||||
category = it.trim()
|
||||
tags.add(Tag("genre", it, false))
|
||||
}
|
||||
|
||||
it.getElementById("Collection")?.children()?.first()?.text()?.let {
|
||||
collection = it.trim()
|
||||
}
|
||||
|
||||
it.getElementById("Group")?.children()?.first()?.text()?.let {
|
||||
group = it.trim()
|
||||
tags.add(Tag("group", it, false))
|
||||
}
|
||||
|
||||
parody.clear()
|
||||
it.getElementById("Parody")?.children()?.forEach {
|
||||
val entry = it.text().trim()
|
||||
parody.add(entry)
|
||||
tags.add(Tag("parody", entry, false))
|
||||
}
|
||||
|
||||
character.clear()
|
||||
it.getElementById("Character")?.children()?.forEach {
|
||||
val entry = it.text().trim()
|
||||
character.add(entry)
|
||||
tags.add(Tag("character", entry, false))
|
||||
}
|
||||
|
||||
it.getElementById("Tag")?.children()?.let {
|
||||
tags.addAll(it.map {
|
||||
Tag(EMULATED_TAG_NAMESPACE, it.text().trim(), false)
|
||||
})
|
||||
|
||||
override fun parseIntoMetadata(metadata: TsuminoSearchMetadata, input: Document) {
|
||||
with(metadata) {
|
||||
tmId = TsuminoSearchMetadata.tmIdFromUrl(input.location()).toInt()
|
||||
tags.clear()
|
||||
|
||||
input.getElementById("Title")?.text()?.let {
|
||||
title = it.trim()
|
||||
}
|
||||
|
||||
input.getElementById("Artist")?.children()?.first()?.text()?.trim()?.let {
|
||||
tags.add(RaisedTag("artist", it, TAG_TYPE_VIRTUAL))
|
||||
artist = it
|
||||
}
|
||||
|
||||
input.getElementById("Uploader")?.children()?.first()?.text()?.trim()?.let {
|
||||
uploader = it
|
||||
}
|
||||
|
||||
input.getElementById("Uploaded")?.text()?.let {
|
||||
uploadDate = TM_DATE_FORMAT.parse(it.trim()).time
|
||||
}
|
||||
|
||||
input.getElementById("Pages")?.text()?.let {
|
||||
length = it.trim().toIntOrNull()
|
||||
}
|
||||
|
||||
input.getElementById("Rating")?.text()?.let {
|
||||
ratingString = it.trim()
|
||||
}
|
||||
|
||||
input.getElementById("Category")?.children()?.first()?.text()?.let {
|
||||
category = it.trim()
|
||||
tags.add(RaisedTag("genre", it, TAG_TYPE_VIRTUAL))
|
||||
}
|
||||
|
||||
input.getElementById("Collection")?.children()?.first()?.text()?.let {
|
||||
collection = it.trim()
|
||||
}
|
||||
|
||||
input.getElementById("Group")?.children()?.first()?.text()?.let {
|
||||
group = it.trim()
|
||||
tags.add(RaisedTag("group", it, TAG_TYPE_VIRTUAL))
|
||||
}
|
||||
|
||||
val newParody = mutableListOf<String>()
|
||||
input.getElementById("Parody")?.children()?.forEach {
|
||||
val entry = it.text().trim()
|
||||
newParody.add(entry)
|
||||
tags.add(RaisedTag("parody", entry, TAG_TYPE_VIRTUAL))
|
||||
}
|
||||
parody = newParody
|
||||
|
||||
val newCharacter = mutableListOf<String>()
|
||||
input.getElementById("Character")?.children()?.forEach {
|
||||
val entry = it.text().trim()
|
||||
newCharacter.add(entry)
|
||||
tags.add(RaisedTag("character", entry, TAG_TYPE_VIRTUAL))
|
||||
}
|
||||
character = newCharacter
|
||||
|
||||
input.getElementById("Tag")?.children()?.let {
|
||||
tags.addAll(it.map {
|
||||
RaisedTag(null, it.text().trim(), TAG_TYPE_DEFAULT)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun genericMangaParse(response: Response): MangasPage {
|
||||
val json = jsonParser.parse(response.body()!!.string()!!).asJsonObject
|
||||
val hasNextPage = json["PageNumber"].int < json["PageCount"].int
|
||||
@ -121,8 +123,8 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
|
||||
SManga.create().apply {
|
||||
val id = obj["Id"].long
|
||||
setUrlWithoutDomain(TsuminoMetadata.mangaUrlFromId(id.toString()))
|
||||
thumbnail_url = TsuminoMetadata.thumbUrlFromId(id.toString())
|
||||
url = TsuminoSearchMetadata.mangaUrlFromId(id.toString())
|
||||
thumbnail_url = BASE_URL + TsuminoSearchMetadata.thumbUrlFromId(id.toString())
|
||||
|
||||
title = obj["Title"].string
|
||||
}
|
||||
@ -199,9 +201,10 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
|
||||
//Support direct URL importing
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList) =
|
||||
urlImportFetchSearchManga(query, {
|
||||
urlImportFetchSearchManga(query) {
|
||||
super.fetchSearchManga(page, query, filters)
|
||||
})
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
// Append filters again, to provide fallback in case a filter is not provided
|
||||
// Since we only work with the first filter when building the result, if the filter is provided,
|
||||
@ -229,15 +232,28 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
override fun searchMangaFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun searchMangaNextPageSelector() = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun searchMangaParse(response: Response) = genericMangaParse(response)
|
||||
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.flatMap {
|
||||
parseToManga(manga, it.asJsoup()).andThen(Observable.just(manga.apply {
|
||||
initialized = true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
override fun mangaDetailsParse(document: Document)
|
||||
= parseToManga(queryFromUrl(document.location()), document)
|
||||
|
||||
= throw UnsupportedOperationException("Unused method called!")
|
||||
|
||||
override fun chapterListSelector() = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun chapterFromElement(element: Element) = throw UnsupportedOperationException("Unused method called!")
|
||||
override fun fetchChapterList(manga: SManga) = lazyLoadMeta(queryFromUrl(manga.url),
|
||||
client.newCall(mangaDetailsRequest(manga)).asObservableSuccess().map { it.asJsoup() }
|
||||
).map {
|
||||
override fun fetchChapterList(manga: SManga) = getOrLoadMetadata(manga.id) {
|
||||
client.newCall(mangaDetailsRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { it.asJsoup() }
|
||||
.toSingle()
|
||||
}.map {
|
||||
trickTsumino(it.tmId)
|
||||
|
||||
listOf(
|
||||
@ -250,9 +266,9 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
chapter_number = 1f
|
||||
}
|
||||
)
|
||||
}
|
||||
}.toObservable()
|
||||
|
||||
fun trickTsumino(id: String?) {
|
||||
fun trickTsumino(id: Int?) {
|
||||
if(id == null) return
|
||||
|
||||
//Make one call to /Read/View (ASP session cookie)
|
||||
@ -330,7 +346,8 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
this,
|
||||
cookiesMap,
|
||||
CAPTCHA_SCRIPT,
|
||||
"$BASE_URL/Read/Auth/$id")
|
||||
"$BASE_URL/Read/Auth/$id",
|
||||
".book-read-button")
|
||||
} catch(t: Throwable) {
|
||||
Crashlytics.logException(t)
|
||||
context.toast("Could not launch captcha-solving activity: ${t.message}")
|
||||
@ -338,7 +355,7 @@ class Tsumino(private val context: Context): ParsedHttpSource(), LewdSource<Tsum
|
||||
}
|
||||
}
|
||||
|
||||
override fun verify(url: String): Boolean {
|
||||
override fun verifyNoCaptcha(url: String): Boolean {
|
||||
return Uri.parse(url).pathSegments.getOrNull(1) == "View"
|
||||
}
|
||||
|
||||
|
@ -141,6 +141,99 @@ open class BrowseCatalogueController(bundle: Bundle) :
|
||||
|
||||
drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED, Gravity.END)
|
||||
|
||||
// EXH -->
|
||||
presenter.loadSearches()?.let {
|
||||
navView.setSavedSearches(it)
|
||||
} ?: run {
|
||||
MaterialDialog.Builder(navView.context)
|
||||
.title("Failed to load saved searches!")
|
||||
.content("An error occurred while loading your saved searches.")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
}
|
||||
navView.onSaveClicked = {
|
||||
MaterialDialog.Builder(navView.context)
|
||||
.title("Save current search query?")
|
||||
.input("My search name", "") { _, searchName ->
|
||||
val oldSavedSearches = presenter.loadSearches() ?: emptyList()
|
||||
if(searchName.isNotBlank()
|
||||
&& oldSavedSearches.size < CatalogueNavigationView.MAX_SAVED_SEARCHES) {
|
||||
val newSearches = oldSavedSearches + EXHSavedSearch(
|
||||
searchName.toString().trim(),
|
||||
presenter.query,
|
||||
presenter.sourceFilters.toList()
|
||||
)
|
||||
presenter.saveSearches(newSearches)
|
||||
navView.setSavedSearches(newSearches)
|
||||
}
|
||||
}
|
||||
.positiveText("Save")
|
||||
.negativeText("Cancel")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
navView.onSavedSearchClicked = cb@{ indexToSearch ->
|
||||
val savedSearches = presenter.loadSearches()
|
||||
|
||||
if(savedSearches == null) {
|
||||
MaterialDialog.Builder(navView.context)
|
||||
.title("Failed to load saved searches!")
|
||||
.content("An error occurred while loading your saved searches.")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
return@cb
|
||||
}
|
||||
|
||||
val search = savedSearches[indexToSearch]
|
||||
|
||||
presenter.sourceFilters = FilterList(search.filterList)
|
||||
navView.setFilters(presenter.filterItems)
|
||||
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
|
||||
|
||||
showProgressBar()
|
||||
adapter?.clear()
|
||||
drawer.closeDrawer(Gravity.END)
|
||||
presenter.restartPager(search.query, if (allDefault) FilterList() else presenter.sourceFilters)
|
||||
activity?.invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
navView.onSavedSearchDeleteClicked = cb@{ indexToDelete ->
|
||||
val savedSearches = presenter.loadSearches()
|
||||
|
||||
if(savedSearches == null) {
|
||||
MaterialDialog.Builder(navView.context)
|
||||
.title("Failed to delete saved search!")
|
||||
.content("An error occurred while deleting the search.")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
return@cb
|
||||
}
|
||||
|
||||
val search = savedSearches[indexToDelete]
|
||||
|
||||
MaterialDialog.Builder(navView.context)
|
||||
.title("Delete saved search query?")
|
||||
.content("Are you sure you wish to delete your saved search query: '${search.name}'?")
|
||||
.positiveText("Cancel")
|
||||
.negativeText("Confirm")
|
||||
.onNegative { _, _ ->
|
||||
val newSearches = savedSearches.filterIndexed { index, _ ->
|
||||
index != indexToDelete
|
||||
}
|
||||
presenter.saveSearches(newSearches)
|
||||
navView.setSavedSearches(newSearches)
|
||||
}
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
}
|
||||
// EXH <--
|
||||
|
||||
navView.onSearchClicked = {
|
||||
val allDefault = presenter.sourceFilters == presenter.source.getFilterList()
|
||||
showProgressBar()
|
||||
|
@ -1,6 +1,9 @@
|
||||
package eu.kanade.tachiyomi.ui.catalogue.browse
|
||||
|
||||
import android.os.Bundle
|
||||
import com.fasterxml.jackson.core.JsonProcessingException
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.davidea.flexibleadapter.items.ISectionable
|
||||
import eu.kanade.tachiyomi.data.cache.CoverCache
|
||||
@ -9,6 +12,7 @@ import eu.kanade.tachiyomi.data.database.models.Category
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaCategory
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
@ -374,4 +378,23 @@ open class BrowseCataloguePresenter(
|
||||
}
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
private val mapper = jacksonObjectMapper().enableDefaultTyping()
|
||||
fun saveSearches(searches: List<EXHSavedSearch>) {
|
||||
val serialized = mapper.writeValueAsString(searches.toTypedArray())
|
||||
prefs.eh_savedSearches().set(serialized)
|
||||
}
|
||||
|
||||
fun loadSearches(): List<EXHSavedSearch>? {
|
||||
val loaded = prefs.eh_savedSearches().getOrDefault()
|
||||
return try {
|
||||
if (!loaded.isEmpty()) mapper.readValue<Array<EXHSavedSearch>>(loaded).toList()
|
||||
else emptyList()
|
||||
} catch(t: JsonProcessingException) {
|
||||
// Load failed
|
||||
Timber.e(t, "Failed to load saved searches!")
|
||||
null
|
||||
}
|
||||
}
|
||||
// EXH <--
|
||||
}
|
||||
|
@ -2,14 +2,20 @@ package eu.kanade.tachiyomi.ui.catalogue.browse
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.davidea.flexibleadapter.items.IFlexible
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.util.dpToPx
|
||||
import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.widget.SimpleNavigationView
|
||||
import kotlinx.android.synthetic.main.catalogue_drawer_content.view.*
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
|
||||
class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null)
|
||||
: SimpleNavigationView(context, attrs) {
|
||||
@ -22,13 +28,26 @@ class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs:
|
||||
|
||||
var onResetClicked = {}
|
||||
|
||||
// EXH -->
|
||||
var onSaveClicked = {}
|
||||
// EXH <--
|
||||
|
||||
// EXH -->
|
||||
var onSavedSearchClicked: (Int) -> Unit = {}
|
||||
// EXH <--
|
||||
|
||||
// EXH -->
|
||||
var onSavedSearchDeleteClicked: (Int) -> Unit = {}
|
||||
// EXH <--
|
||||
|
||||
init {
|
||||
recycler.adapter = adapter
|
||||
recycler.setHasFixedSize(true)
|
||||
val view = inflate(R.layout.catalogue_drawer_content)
|
||||
val view = inflate(eu.kanade.tachiyomi.R.layout.catalogue_drawer_content)
|
||||
((view as ViewGroup).getChildAt(1) as ViewGroup).addView(recycler)
|
||||
addView(view)
|
||||
title.text = context?.getString(R.string.source_search_options)
|
||||
title.text = context?.getString(eu.kanade.tachiyomi.R.string.source_search_options)
|
||||
save_search_btn.setOnClickListener { onSaveClicked() }
|
||||
search_btn.setOnClickListener { onSearchClicked() }
|
||||
reset_btn.setOnClickListener { onResetClicked() }
|
||||
}
|
||||
@ -37,4 +56,33 @@ class CatalogueNavigationView @JvmOverloads constructor(context: Context, attrs:
|
||||
adapter.updateDataSet(items)
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
fun setSavedSearches(searches: List<EXHSavedSearch>) {
|
||||
saved_searches.removeAllViews()
|
||||
|
||||
val outValue = TypedValue()
|
||||
context.theme.resolveAttribute(android.R.attr.selectableItemBackground, outValue, true)
|
||||
|
||||
save_search_btn.visibility = if(searches.size < 5) View.VISIBLE else View.GONE
|
||||
|
||||
searches.forEachIndexed { index, search ->
|
||||
val restoreBtn = TextView(context)
|
||||
restoreBtn.text = search.name
|
||||
val params = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||
params.gravity = Gravity.CENTER
|
||||
restoreBtn.layoutParams = params
|
||||
restoreBtn.gravity = Gravity.CENTER
|
||||
restoreBtn.setBackgroundResource(outValue.resourceId)
|
||||
restoreBtn.setPadding(8.dpToPx, 8.dpToPx, 8.dpToPx, 8.dpToPx)
|
||||
restoreBtn.setOnClickListener { onSavedSearchClicked(index) }
|
||||
restoreBtn.setOnLongClickListener { onSavedSearchDeleteClicked(index); true }
|
||||
saved_searches.addView(restoreBtn)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_SAVED_SEARCHES = 5
|
||||
}
|
||||
// EXH <--
|
||||
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.catalogue.browse
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
|
||||
data class EXHSavedSearch(val name: String,
|
||||
val query: String,
|
||||
val filterList: List<Filter<*>>)
|
@ -1,17 +1,14 @@
|
||||
package eu.kanade.tachiyomi.ui.library
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.davidea.flexibleadapter.FlexibleAdapter
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import exh.*
|
||||
import exh.metadata.metadataClass
|
||||
import exh.metadata.models.SearchableGalleryMetadata
|
||||
import exh.metadata.syncMangaIds
|
||||
import exh.isLewdSource
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
import exh.search.SearchEngine
|
||||
import exh.util.defRealm
|
||||
import io.realm.RealmResults
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.concurrent.thread
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
/**
|
||||
* Adapter storing a list of manga in a certain category.
|
||||
@ -21,6 +18,7 @@ import kotlin.concurrent.thread
|
||||
class LibraryCategoryAdapter(val view: LibraryCategoryView) :
|
||||
FlexibleAdapter<LibraryItem>(null, view, true) {
|
||||
// --> EH
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
private val searchEngine = SearchEngine()
|
||||
// <-- EH
|
||||
|
||||
@ -38,15 +36,6 @@ class LibraryCategoryAdapter(val view: LibraryCategoryView) :
|
||||
// A copy of manga always unfiltered.
|
||||
mangas = list.toList()
|
||||
|
||||
// Sync manga IDs in background (EH)
|
||||
thread {
|
||||
//Wait 1s to reduce UI stutter during animations
|
||||
Thread.sleep(2000)
|
||||
defRealm {
|
||||
it.syncMangaIds(mangas)
|
||||
}
|
||||
}
|
||||
|
||||
performFilter()
|
||||
}
|
||||
|
||||
@ -61,104 +50,43 @@ class LibraryCategoryAdapter(val view: LibraryCategoryView) :
|
||||
|
||||
fun performFilter() {
|
||||
if(searchText.isNotBlank()) {
|
||||
if(cacheText != searchText) {
|
||||
globalSearchCache.clear()
|
||||
cacheText = searchText
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
try {
|
||||
val thisCache = globalSearchCache.getOrPut(view.category.name) {
|
||||
SearchCache(mangas.size)
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
val parsedQuery = searchEngine.parseQuery(searchText)
|
||||
val sqlQuery = searchEngine.queryToSql(parsedQuery)
|
||||
val queryResult = db.lowLevel().rawQuery(RawQuery.builder()
|
||||
.query(sqlQuery.first)
|
||||
.args(*sqlQuery.second.toTypedArray())
|
||||
.build())
|
||||
|
||||
val convertedResult = ArrayList<Long>(queryResult.count)
|
||||
val mangaIdCol = queryResult.getColumnIndex(SearchMetadataTable.COL_MANGA_ID)
|
||||
queryResult.moveToFirst()
|
||||
while(queryResult.count > 0 && !queryResult.isAfterLast) {
|
||||
convertedResult += queryResult.getLong(mangaIdCol)
|
||||
queryResult.moveToNext()
|
||||
}
|
||||
|
||||
if(thisCache.ready) {
|
||||
//Skip everything if cache matches our query exactly
|
||||
updateDataSet(mangas.filter {
|
||||
thisCache.cache[it.manga.id] ?: false
|
||||
})
|
||||
} else {
|
||||
thisCache.cache.clear()
|
||||
|
||||
val parsedQuery = searchEngine.parseQuery(searchText)
|
||||
var totalFilteredSize = 0
|
||||
|
||||
val metadata = view.controller.meta!!.map {
|
||||
val meta: RealmResults<out SearchableGalleryMetadata> = if (it.value.isNotEmpty())
|
||||
searchEngine.filterResults(it.value.where(),
|
||||
parsedQuery,
|
||||
it.value.first()!!.titleFields)
|
||||
.sort(SearchableGalleryMetadata::mangaId.name)
|
||||
.findAll().apply {
|
||||
totalFilteredSize += size
|
||||
}
|
||||
else
|
||||
it.value
|
||||
Pair(it.key, meta)
|
||||
}.toMap()
|
||||
|
||||
val out = ArrayList<LibraryItem>(mangas.size)
|
||||
|
||||
var lewdMatches = 0
|
||||
|
||||
for(manga in mangas) {
|
||||
// --> EH
|
||||
try {
|
||||
if (isLewdSource(manga.manga.source)) {
|
||||
//Stop matching lewd manga if we have matched them all already!
|
||||
if (lewdMatches >= totalFilteredSize)
|
||||
continue
|
||||
|
||||
val metaClass = manga.manga.metadataClass
|
||||
val unfilteredMeta = view.controller.meta!![metaClass]
|
||||
val filteredMeta = metadata[metaClass]
|
||||
|
||||
val hasMeta = manga.hasMetadata ?: (unfilteredMeta
|
||||
?.where()
|
||||
?.equalTo(SearchableGalleryMetadata::mangaId.name, manga.manga.id)
|
||||
?.count() ?: 0 > 0)
|
||||
|
||||
if (hasMeta) {
|
||||
if (filteredMeta!!.where()
|
||||
.equalTo(SearchableGalleryMetadata::mangaId.name, manga.manga.id)
|
||||
.count() > 0) {
|
||||
//Metadata match!
|
||||
lewdMatches++
|
||||
thisCache.cache[manga.manga.id!!] = true
|
||||
out += manga
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Could not filter manga! %s", manga.manga)
|
||||
}
|
||||
|
||||
//Fallback to regular filter
|
||||
val filterRes = manga.filter(searchText)
|
||||
thisCache.cache[manga.manga.id!!] = filterRes
|
||||
if(filterRes) out += manga
|
||||
// <-- EH
|
||||
val out = mangas.filter {
|
||||
if(isLewdSource(it.manga.source)) {
|
||||
convertedResult.binarySearch(it.manga.id) >= 0
|
||||
} else {
|
||||
it.filter(searchText)
|
||||
}
|
||||
thisCache.ready = true
|
||||
updateDataSet(out)
|
||||
}
|
||||
|
||||
Timber.d("===> Took %s milliseconds to filter manga!", System.currentTimeMillis() - startTime)
|
||||
|
||||
updateDataSet(out)
|
||||
} catch(e: Exception) {
|
||||
Timber.w(e, "Could not filter mangas!")
|
||||
updateDataSet(mangas)
|
||||
}
|
||||
// EXH <--
|
||||
} else {
|
||||
globalSearchCache.clear()
|
||||
updateDataSet(mangas)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchCache(size: Int) {
|
||||
var ready = false
|
||||
var cache = HashMap<Long, Boolean>(size)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var cacheText: String? = null
|
||||
val globalSearchCache = ConcurrentHashMap<String, SearchCache>()
|
||||
}
|
||||
}
|
||||
|
@ -114,7 +114,7 @@ class LibraryCategoryView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
subscriptions += controller.searchRelay
|
||||
.doOnNext { adapter.searchText = it }
|
||||
.skip(1)
|
||||
.debounce(350, TimeUnit.MILLISECONDS)
|
||||
.debounce(500, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { adapter.performFilter() }
|
||||
|
||||
|
@ -38,10 +38,6 @@ import eu.kanade.tachiyomi.util.inflate
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import exh.favorites.FavoritesIntroDialog
|
||||
import exh.favorites.FavoritesSyncStatus
|
||||
import exh.metadata.loadAllMetadata
|
||||
import exh.metadata.models.SearchableGalleryMetadata
|
||||
import io.realm.Realm
|
||||
import io.realm.RealmResults
|
||||
import kotlinx.android.synthetic.main.library_controller.*
|
||||
import kotlinx.android.synthetic.main.main_activity.*
|
||||
import rx.Subscription
|
||||
@ -51,7 +47,6 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
class LibraryController(
|
||||
@ -130,10 +125,6 @@ class LibraryController(
|
||||
private var searchViewSubscription: Subscription? = null
|
||||
|
||||
// --> EH
|
||||
//Cached realm
|
||||
var realm: Realm? = null
|
||||
//Cached metadata
|
||||
var meta: Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>>? = null
|
||||
//Sync dialog
|
||||
private var favSyncDialog: MaterialDialog? = null
|
||||
//Old sync status
|
||||
@ -159,16 +150,6 @@ class LibraryController(
|
||||
return inflater.inflate(R.layout.library_controller, container, false)
|
||||
}
|
||||
|
||||
// --> EH
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup, savedViewState: Bundle?): View {
|
||||
//Load realm
|
||||
realm = Realm.getDefaultInstance()?.apply {
|
||||
meta = loadAllMetadata()
|
||||
}
|
||||
return super.onCreateView(inflater, container, savedViewState)
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
override fun onViewCreated(view: View) {
|
||||
super.onViewCreated(view)
|
||||
|
||||
@ -205,12 +186,6 @@ class LibraryController(
|
||||
tabsVisibilitySubscription?.unsubscribe()
|
||||
tabsVisibilitySubscription = null
|
||||
super.onDestroyView(view)
|
||||
|
||||
// --> EH
|
||||
//Clean up realm
|
||||
realm?.close()
|
||||
meta = null
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
override fun createSecondaryDrawer(drawer: DrawerLayout): ViewGroup {
|
||||
|
@ -15,7 +15,6 @@ import android.support.v7.graphics.drawable.DrawerArrowDrawable
|
||||
import android.support.v7.widget.Toolbar
|
||||
import android.view.ViewGroup
|
||||
import com.bluelinelabs.conductor.*
|
||||
import eu.kanade.tachiyomi.Migrations
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
@ -29,27 +28,20 @@ import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.ui.recent_updates.RecentChaptersController
|
||||
import eu.kanade.tachiyomi.ui.recently_read.RecentlyReadController
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsMainController
|
||||
import exh.metadata.loadAllMetadata
|
||||
import exh.uconfig.WarnConfigureDialogController
|
||||
import exh.ui.batchadd.BatchAddController
|
||||
import exh.ui.lock.LockChangeHandler
|
||||
import exh.ui.lock.LockController
|
||||
import exh.ui.lock.lockEnabled
|
||||
import exh.ui.lock.notifyLockSecurity
|
||||
import exh.ui.migration.MetadataFetchDialog
|
||||
import exh.util.defRealm
|
||||
import kotlinx.android.synthetic.main.main_activity.*
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.all.Hitomi
|
||||
import eu.kanade.tachiyomi.util.vibrate
|
||||
import exh.HITOMI_SOURCE_ID
|
||||
import rx.schedulers.Schedulers
|
||||
import exh.EXHMigrations
|
||||
import exh.ui.migration.MetadataFetchDialog
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
|
||||
class MainActivity : BaseActivity() {
|
||||
@ -176,35 +168,32 @@ class MainActivity : BaseActivity() {
|
||||
notifyLockSecurity(this)
|
||||
}
|
||||
}
|
||||
|
||||
// Early hitomi.la refresh
|
||||
if(preferences.eh_hl_earlyRefresh().getOrDefault()) {
|
||||
(Injekt.get<SourceManager>().get(HITOMI_SOURCE_ID) as Hitomi)
|
||||
.ensureCacheLoaded(false)
|
||||
.subscribeOn(Schedulers.computation())
|
||||
.subscribe()
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
syncActivityViewWithController(router.backstack.lastOrNull()?.controller())
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
// Show changelog if needed
|
||||
if (Migrations.upgrade(preferences)) {
|
||||
// TODO
|
||||
// if (Migrations.upgrade(preferences)) {
|
||||
// ChangelogDialogController().showDialog(router)
|
||||
// }
|
||||
|
||||
// EXH -->
|
||||
// Perform EXH specific migrations
|
||||
if(EXHMigrations.upgrade(preferences)) {
|
||||
ChangelogDialogController().showDialog(router)
|
||||
}
|
||||
|
||||
// Migrate metadata if empty (EH)
|
||||
if(!defRealm {
|
||||
it.loadAllMetadata().any {
|
||||
it.value.isNotEmpty()
|
||||
}
|
||||
}) MetadataFetchDialog().askMigration(this, false)
|
||||
if(!preferences.migrateLibraryAsked().getOrDefault()) {
|
||||
MetadataFetchDialog().askMigration(this, false)
|
||||
}
|
||||
|
||||
// Upload settings
|
||||
if(preferences.enableExhentai().getOrDefault()
|
||||
&& preferences.eh_showSettingsUploadWarning().getOrDefault())
|
||||
WarnConfigureDialogController.uploadSettings(router)
|
||||
// EXH <--
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,6 +53,7 @@ import exh.ui.webview.WebViewActivity
|
||||
import jp.wasabeef.glide.transformations.CropSquareTransformation
|
||||
import jp.wasabeef.glide.transformations.MaskTransformation
|
||||
import kotlinx.android.synthetic.main.manga_info_controller.*
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.DateFormat
|
||||
import java.text.DecimalFormat
|
||||
@ -139,7 +140,7 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
|
||||
var text = tag
|
||||
if(isEHentaiBasedSource()) {
|
||||
val parsed = parseTag(text)
|
||||
text = wrapTag(parsed.first, parsed.second)
|
||||
text = wrapTag(parsed.first, parsed.second.substringBefore('|').trim())
|
||||
}
|
||||
performGlobalSearch(text)
|
||||
}
|
||||
@ -386,6 +387,9 @@ class MangaInfoController : NucleusController<MangaInfoPresenter>(),
|
||||
fun onFetchMangaError(error: Throwable) {
|
||||
setRefreshing(false)
|
||||
activity?.toast(error.message)
|
||||
// EXH -->
|
||||
Timber.e(error, "Failed to fetch manga details!")
|
||||
// EXH <--
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -14,23 +14,29 @@ import android.view.*
|
||||
import android.view.animation.Animation
|
||||
import android.view.animation.AnimationUtils
|
||||
import android.widget.SeekBar
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||
import com.jakewharton.rxbinding.view.clicks
|
||||
import com.jakewharton.rxbinding.widget.checkedChanges
|
||||
import com.jakewharton.rxbinding.widget.textChanges
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.all.EHentai
|
||||
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.AddToLibraryFirst
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Error
|
||||
import eu.kanade.tachiyomi.ui.reader.ReaderPresenter.SetAsCoverResult.Success
|
||||
import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.BaseViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.pager.*
|
||||
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
|
||||
import eu.kanade.tachiyomi.util.*
|
||||
import eu.kanade.tachiyomi.widget.SimpleAnimationListener
|
||||
@ -46,6 +52,7 @@ import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.roundToLong
|
||||
|
||||
/**
|
||||
* Activity containing the reader of Tachiyomi. This activity is mostly a container of the
|
||||
@ -76,6 +83,15 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
var menuVisible = false
|
||||
private set
|
||||
|
||||
// --> EH
|
||||
private var ehUtilsVisible = false
|
||||
|
||||
private val exhSubscriptions = CompositeSubscription()
|
||||
|
||||
private var autoscrollSubscription: Subscription? = null
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
// <-- EH
|
||||
|
||||
/**
|
||||
* System UI helper to hide status & navigation bar on all different API levels.
|
||||
*/
|
||||
@ -132,12 +148,49 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
|
||||
if (savedState != null) {
|
||||
menuVisible = savedState.getBoolean(::menuVisible.name)
|
||||
// --> EH
|
||||
ehUtilsVisible = savedState.getBoolean(::ehUtilsVisible.name)
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
config = ReaderConfig()
|
||||
initializeMenu()
|
||||
}
|
||||
|
||||
// --> EH
|
||||
private fun setEhUtilsVisibility(visible: Boolean) {
|
||||
if(visible) {
|
||||
eh_utils.visible()
|
||||
expand_eh_button.setImageResource(R.drawable.ic_keyboard_arrow_up_white_32dp)
|
||||
} else {
|
||||
eh_utils.gone()
|
||||
expand_eh_button.setImageResource(R.drawable.ic_keyboard_arrow_down_white_32dp)
|
||||
}
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
// --> EH
|
||||
private fun setupAutoscroll(interval: Float) {
|
||||
exhSubscriptions.remove(autoscrollSubscription)
|
||||
autoscrollSubscription = null
|
||||
|
||||
if(interval == -1f) return
|
||||
|
||||
val intervalMs = (interval * 1000).roundToLong()
|
||||
val sub = Observable.interval(intervalMs, intervalMs, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
viewer.let { v ->
|
||||
if(v is PagerViewer) v.moveToNext()
|
||||
else if(v is WebtoonViewer) v.scrollDown()
|
||||
}
|
||||
}
|
||||
|
||||
autoscrollSubscription = sub
|
||||
exhSubscriptions += sub
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
/**
|
||||
* Called when the activity is destroyed. Cleans up the viewer, configuration and any view.
|
||||
*/
|
||||
@ -157,6 +210,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
*/
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putBoolean(::menuVisible.name, menuVisible)
|
||||
// EXH -->
|
||||
outState.putBoolean(::ehUtilsVisible.name, ehUtilsVisible)
|
||||
// EXH <--
|
||||
if (!isChangingConfigurations) {
|
||||
presenter.onSaveInstanceStateNonConfigurationChange()
|
||||
}
|
||||
@ -257,10 +313,151 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
}
|
||||
}
|
||||
|
||||
// --> EH
|
||||
exhSubscriptions += expand_eh_button.clicks().subscribe {
|
||||
ehUtilsVisible = !ehUtilsVisible
|
||||
setEhUtilsVisibility(ehUtilsVisible)
|
||||
}
|
||||
|
||||
eh_autoscroll_freq.setText(preferences.eh_utilAutoscrollInterval().getOrDefault().let {
|
||||
if(it == -1f)
|
||||
""
|
||||
else it.toString()
|
||||
})
|
||||
|
||||
exhSubscriptions += eh_autoscroll.checkedChanges()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
setupAutoscroll(if(it)
|
||||
preferences.eh_utilAutoscrollInterval().getOrDefault()
|
||||
else -1f)
|
||||
}
|
||||
|
||||
exhSubscriptions += eh_autoscroll_freq.textChanges()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
val parsed = it?.toString()?.toFloatOrNull()
|
||||
|
||||
if (parsed == null || parsed <= 0 || parsed > 9999) {
|
||||
eh_autoscroll_freq.error = "Invalid frequency"
|
||||
preferences.eh_utilAutoscrollInterval().set(-1f)
|
||||
eh_autoscroll.isEnabled = false
|
||||
setupAutoscroll(-1f)
|
||||
} else {
|
||||
eh_autoscroll_freq.error = null
|
||||
preferences.eh_utilAutoscrollInterval().set(parsed)
|
||||
eh_autoscroll.isEnabled = true
|
||||
setupAutoscroll(if(eh_autoscroll.isChecked) parsed else -1f)
|
||||
}
|
||||
}
|
||||
|
||||
exhSubscriptions += eh_autoscroll_help.clicks().subscribe {
|
||||
MaterialDialog.Builder(this)
|
||||
.title("Autoscroll help")
|
||||
.content("Automatically scroll to the next page in the specified interval. Interval is specified in seconds.")
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
|
||||
exhSubscriptions += eh_retry_all.clicks().subscribe {
|
||||
var retried = 0
|
||||
|
||||
presenter.viewerChaptersRelay.value
|
||||
.currChapter
|
||||
.pages
|
||||
?.forEachIndexed { index, page ->
|
||||
var shouldQueuePage = false
|
||||
if(page.status == Page.ERROR) {
|
||||
shouldQueuePage = true
|
||||
} else if(page.status == Page.LOAD_PAGE
|
||||
|| page.status == Page.DOWNLOAD_IMAGE) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
if(shouldQueuePage) {
|
||||
page.status = Page.QUEUE
|
||||
} else {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
//If we are using EHentai/ExHentai, get a new image URL
|
||||
presenter.manga?.let { m ->
|
||||
val src = sourceManager.get(m.source)
|
||||
if(src is EHentai)
|
||||
page.imageUrl = null
|
||||
}
|
||||
|
||||
val loader = page.chapter.pageLoader
|
||||
if(page.index == exh_currentPage()?.index && loader is HttpPageLoader) {
|
||||
loader.boostPage(page)
|
||||
} else {
|
||||
loader?.retryPage(page)
|
||||
}
|
||||
|
||||
retried++
|
||||
}
|
||||
|
||||
toast("Retrying $retried failed pages...")
|
||||
}
|
||||
|
||||
exhSubscriptions += eh_retry_all_help.clicks().subscribe {
|
||||
MaterialDialog.Builder(this)
|
||||
.title("Retry all help")
|
||||
.content("Re-add all failed pages to the download queue.")
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
|
||||
exhSubscriptions += eh_boost_page.clicks().subscribe {
|
||||
viewer?.let { viewer ->
|
||||
val curPage = exh_currentPage() ?: run {
|
||||
toast("This page cannot be boosted (invalid page)!")
|
||||
return@let
|
||||
}
|
||||
|
||||
if(curPage.status == Page.ERROR) {
|
||||
toast("Page failed to load, press the retry button instead!")
|
||||
} else if(curPage.status == Page.LOAD_PAGE || curPage.status == Page.DOWNLOAD_IMAGE) {
|
||||
toast("This page is already downloading!")
|
||||
} else if(curPage.status == Page.READY) {
|
||||
toast("This page has already been downloaded!")
|
||||
} else {
|
||||
val loader = (presenter.viewerChaptersRelay.value.currChapter.pageLoader as? HttpPageLoader)
|
||||
if(loader != null) {
|
||||
loader.boostPage(curPage)
|
||||
toast("Boosted current page!")
|
||||
} else {
|
||||
toast("This page cannot be boosted (invalid page loader)!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exhSubscriptions += eh_boost_page_help.clicks().subscribe {
|
||||
MaterialDialog.Builder(this)
|
||||
.title("Boost page help")
|
||||
.content("Normally the downloader can only download a specific amount of pages at the same time. This means you can be waiting for a page to download but the downloader will not start downloading the page until it has a free download slot. Pressing 'Boost page' will force the downloader to begin downloading the current page, regardless of whether or not there is an available slot.")
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
// <-- EH
|
||||
|
||||
// Set initial visibility
|
||||
setMenuVisibility(menuVisible)
|
||||
|
||||
// --> EH
|
||||
setEhUtilsVisibility(ehUtilsVisible)
|
||||
// <-- EH
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
private fun exh_currentPage(): ReaderPage? {
|
||||
val currentPage = (((viewer as? PagerViewer)?.currentPage
|
||||
?: (viewer as? WebtoonViewer)?.currentPage) as? ReaderPage)?.index
|
||||
return currentPage?.let { presenter.viewerChaptersRelay.value.currChapter.pages?.getOrNull(it) }
|
||||
}
|
||||
// EXH <--
|
||||
|
||||
/**
|
||||
* Sets the visibility of the menu according to [visible] and with an optional parameter to
|
||||
* [animate] the views.
|
||||
@ -282,7 +479,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
}
|
||||
}
|
||||
})
|
||||
toolbar.startAnimation(toolbarAnimation)
|
||||
// EXH -->
|
||||
header.startAnimation(toolbarAnimation)
|
||||
// EXH <--
|
||||
|
||||
val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.enter_from_bottom)
|
||||
reader_menu_bottom.startAnimation(bottomAnimation)
|
||||
@ -297,7 +496,9 @@ class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
|
||||
reader_menu.visibility = View.GONE
|
||||
}
|
||||
})
|
||||
toolbar.startAnimation(toolbarAnimation)
|
||||
// EXH -->
|
||||
header.startAnimation(toolbarAnimation)
|
||||
// EXH <--
|
||||
|
||||
val bottomAnimation = AnimationUtils.loadAnimation(this, R.anim.exit_to_bottom)
|
||||
reader_menu_bottom.startAnimation(bottomAnimation)
|
||||
|
@ -70,7 +70,7 @@ class ReaderPresenter(
|
||||
/**
|
||||
* Relay for currently active viewer chapters.
|
||||
*/
|
||||
private val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
|
||||
/* [EXH] private */ val viewerChaptersRelay = BehaviorRelay.create<ViewerChapters>()
|
||||
|
||||
/**
|
||||
* Relay used when loading prev/next chapter needed to lock the UI (with a dialog).
|
||||
|
@ -1,6 +1,8 @@
|
||||
package eu.kanade.tachiyomi.ui.reader.loader
|
||||
|
||||
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||
@ -15,6 +17,7 @@ import rx.subscriptions.CompositeSubscription
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@ -26,6 +29,9 @@ class HttpPageLoader(
|
||||
private val source: HttpSource,
|
||||
private val chapterCache: ChapterCache = Injekt.get()
|
||||
) : PageLoader() {
|
||||
// EXH -->
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
// EXH <--
|
||||
|
||||
/**
|
||||
* A queue used to manage requests one by one while allowing priorities.
|
||||
@ -38,17 +44,23 @@ class HttpPageLoader(
|
||||
private val subscriptions = CompositeSubscription()
|
||||
|
||||
init {
|
||||
subscriptions += Observable.defer { Observable.just(queue.take().page) }
|
||||
.filter { it.status == Page.QUEUE }
|
||||
.concatMap { source.fetchImageFromCacheThenNet(it) }
|
||||
.repeat()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
if (error !is InterruptedException) {
|
||||
Timber.e(error)
|
||||
}
|
||||
})
|
||||
// EXH -->
|
||||
repeat(prefs.eh_readerThreads().getOrDefault()) {
|
||||
// EXH <--
|
||||
subscriptions += Observable.defer { Observable.just(queue.take().page) }
|
||||
.filter { it.status == Page.QUEUE }
|
||||
.concatMap { source.fetchImageFromCacheThenNet(it) }
|
||||
.repeat()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
if (error !is InterruptedException) {
|
||||
Timber.e(error)
|
||||
}
|
||||
})
|
||||
// EXH -->
|
||||
}
|
||||
// EXH <--
|
||||
}
|
||||
|
||||
/**
|
||||
@ -142,6 +154,9 @@ class HttpPageLoader(
|
||||
if (page.status == Page.ERROR) {
|
||||
page.status = Page.QUEUE
|
||||
}
|
||||
// EXH -->
|
||||
if(prefs.eh_readerInstantRetry().getOrDefault()) boostPage(page)
|
||||
else // EXH <--
|
||||
queue.offer(PriorityPage(page, 2))
|
||||
}
|
||||
|
||||
@ -223,4 +238,20 @@ class HttpPageLoader(
|
||||
.doOnNext { chapterCache.putImageToCache(page.imageUrl!!, it) }
|
||||
.map { page }
|
||||
}
|
||||
|
||||
// EXH -->
|
||||
fun boostPage(page: ReaderPage) {
|
||||
if(page.status == Page.QUEUE) {
|
||||
subscriptions += Observable.just(page)
|
||||
.concatMap { source.fetchImageFromCacheThenNet(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe({
|
||||
}, { error ->
|
||||
if (error !is InterruptedException) {
|
||||
Timber.e(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// EXH <--
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
private var currentPage: Any? = null
|
||||
/* [EXH] private */ var currentPage: Any? = null
|
||||
|
||||
/**
|
||||
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
|
||||
|
@ -48,7 +48,7 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
/**
|
||||
* Currently active item. It can be a chapter page or a chapter transition.
|
||||
*/
|
||||
private var currentPage: Any? = null
|
||||
/* [EXH] private */ var currentPage: Any? = null
|
||||
|
||||
/**
|
||||
* Configuration used by this viewer, like allow taps, or crop image borders.
|
||||
@ -200,7 +200,7 @@ class WebtoonViewer(val activity: ReaderActivity) : BaseViewer {
|
||||
/**
|
||||
* Scrolls down by [scrollDistance].
|
||||
*/
|
||||
private fun scrollDown() {
|
||||
/* [EXH] private */ fun scrollDown() {
|
||||
recycler.smoothScrollBy(0, scrollDistance)
|
||||
}
|
||||
|
||||
|
@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.ui.setting
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import android.text.Html
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import com.bluelinelabs.conductor.RouterTransaction
|
||||
import com.bluelinelabs.conductor.changehandler.FadeChangeHandler
|
||||
@ -15,12 +15,13 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService
|
||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
|
||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||
import eu.kanade.tachiyomi.source.SourceManager.Companion.DELEGATED_SOURCES
|
||||
import eu.kanade.tachiyomi.ui.base.controller.DialogController
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.library.LibraryController
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import exh.debug.SettingsDebugController
|
||||
import exh.ui.migration.MetadataFetchDialog
|
||||
import exh.util.realmTrans
|
||||
import io.realm.Realm
|
||||
import rx.Observable
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
@ -74,6 +75,8 @@ class SettingsAdvancedController : SettingsController() {
|
||||
|
||||
onClick { LibraryUpdateService.start(context, target = Target.TRACKING) }
|
||||
}
|
||||
|
||||
// --> EXH
|
||||
preferenceCategory {
|
||||
title = "Gallery metadata"
|
||||
isPersistent = false
|
||||
@ -98,14 +101,29 @@ class SettingsAdvancedController : SettingsController() {
|
||||
summary = "Clear all library metadata. Disables tag searching in the library"
|
||||
|
||||
onClick {
|
||||
realmTrans {
|
||||
it.deleteAll()
|
||||
db.inTransaction {
|
||||
db.deleteAllSearchMetadata().executeAsBlocking()
|
||||
db.deleteAllSearchTags().executeAsBlocking()
|
||||
db.deleteAllSearchTitle().executeAsBlocking()
|
||||
}
|
||||
|
||||
context.toast("Library metadata cleared!")
|
||||
}
|
||||
}
|
||||
}
|
||||
switchPreference {
|
||||
title = "Enable delegated sources"
|
||||
key = PreferenceKeys.eh_delegateSources
|
||||
defaultValue = true
|
||||
summary = "Apply TachiyomiEH enhancements to the following sources if they are installed: ${DELEGATED_SOURCES.values.joinToString { it.sourceName }}"
|
||||
}
|
||||
|
||||
preference {
|
||||
title = "Open debug menu"
|
||||
summary = Html.fromHtml("DO NOT TOUCH THIS MENU UNLESS YOU KNOW WHAT YOU ARE DOING! <font color='red'>IT CAN CORRUPT YOUR LIBRARY!</font>")
|
||||
onClick { router.pushController(SettingsDebugController().withFadeTransaction()) }
|
||||
}
|
||||
// <-- EXH
|
||||
}
|
||||
|
||||
private fun clearChapterCache() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package eu.kanade.tachiyomi.ui.setting
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
@ -180,7 +181,7 @@ class SettingsGeneralController : SettingsController() {
|
||||
}
|
||||
}
|
||||
|
||||
// --> EH
|
||||
// --> EXH
|
||||
switchPreference {
|
||||
key = Keys.eh_askCategoryOnLongPress
|
||||
title = "Long-press favorite button to specify category"
|
||||
@ -193,6 +194,14 @@ class SettingsGeneralController : SettingsController() {
|
||||
defaultValue = false
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
key = Keys.eh_autoSolveCaptchas
|
||||
title = "Automatically solve captcha"
|
||||
summary = "Use HIGHLY EXPERIMENTAL automatic ReCAPTCHA solver. Will be grayed out if unsupported by your device."
|
||||
defaultValue = false
|
||||
shouldDisableView = Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
key = Keys.eh_incogWebview
|
||||
title = "Incognito 'Open in browser'"
|
||||
@ -228,7 +237,7 @@ class SettingsGeneralController : SettingsController() {
|
||||
defaultValue = false
|
||||
}
|
||||
}
|
||||
// <-- EH
|
||||
// <-- EXH
|
||||
}
|
||||
|
||||
class LibraryColumnsDialog : DialogController() {
|
||||
|
@ -4,7 +4,6 @@ import android.support.v7.preference.PreferenceScreen
|
||||
import android.widget.Toast
|
||||
import eu.kanade.tachiyomi.data.preference.PreferenceKeys
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import eu.kanade.tachiyomi.source.online.all.Hitomi
|
||||
import eu.kanade.tachiyomi.util.toast
|
||||
import exh.HITOMI_SOURCE_ID
|
||||
import uy.kohesive.injekt.Injekt
|
||||
@ -18,41 +17,6 @@ class SettingsHlController : SettingsController() {
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
|
||||
title = "hitomi.la"
|
||||
|
||||
editTextPreference {
|
||||
title = "Search database refresh frequency"
|
||||
summary = "How often to get new entries for the search database in hours. Setting this frequency too high may cause high CPU usage and network usage."
|
||||
key = PreferenceKeys.eh_hl_refreshFrequency
|
||||
defaultValue = "24"
|
||||
|
||||
onChange {
|
||||
it as String
|
||||
|
||||
if((it.toLongOrNull() ?: -1) <= 0) {
|
||||
context.toast("Invalid frequency. Frequency must be a positive whole number.")
|
||||
false
|
||||
} else true
|
||||
}
|
||||
}
|
||||
|
||||
switchPreference {
|
||||
title = "Begin refreshing search database on app launch"
|
||||
summary = "Normally the search database gets refreshed (if required) when you open the hitomi.la catalogue. If you enable this option, the database gets refreshed in the background as soon as you open the app. It will result in higher data usage but may increase hitomi.la search speeds."
|
||||
key = PreferenceKeys.eh_hl_earlyRefresh
|
||||
defaultValue = false
|
||||
}
|
||||
|
||||
preference {
|
||||
title = "Force refresh search database now"
|
||||
summary = "Delete the local copy of the hitomi.la search database and download the new database now. Hitomi.la search will not work in the ~10mins that it takes to refresh the search database"
|
||||
isPersistent = false
|
||||
|
||||
onClick {
|
||||
context.toast(if((Injekt.get<SourceManager>().get(HITOMI_SOURCE_ID) as Hitomi).forceEnsureCacheLoaded()) {
|
||||
"Refreshing database. You will NOT be notified when it is complete!"
|
||||
} else {
|
||||
"Could not begin refresh process as there is already one ongoing!"
|
||||
}, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
// TODO Thumbnail quality chooser
|
||||
}
|
||||
}
|
||||
|
@ -86,6 +86,7 @@ class SettingsReaderController : SettingsController() {
|
||||
defaultValue = false
|
||||
}
|
||||
}
|
||||
// EXH -->
|
||||
intListPreference {
|
||||
key = Keys.eh_readerThreads
|
||||
title = "Download threads"
|
||||
@ -147,6 +148,7 @@ class SettingsReaderController : SettingsController() {
|
||||
title = "Preserve reading position on read manga"
|
||||
defaultValue = false
|
||||
}
|
||||
// EXH <--
|
||||
preferenceCategory {
|
||||
titleRes = R.string.pager_viewer
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
package exh
|
||||
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
|
||||
/**
|
||||
* Source helpers
|
||||
*/
|
||||
@ -15,13 +17,17 @@ const val PERV_EDEN_IT_SOURCE_ID = LEWD_SOURCE_SERIES + 6
|
||||
|
||||
const val NHENTAI_SOURCE_ID = LEWD_SOURCE_SERIES + 7
|
||||
|
||||
@Deprecated("Now a delegated source")
|
||||
const val HENTAI_CAFE_SOURCE_ID = LEWD_SOURCE_SERIES + 8
|
||||
|
||||
const val TSUMINO_SOURCE_ID = LEWD_SOURCE_SERIES + 9
|
||||
|
||||
const val HITOMI_SOURCE_ID = LEWD_SOURCE_SERIES + 10
|
||||
|
||||
fun isLewdSource(source: Long) = source in 6900..6999
|
||||
// TODO hentai.cafe is a lewd source!
|
||||
fun isLewdSource(source: Long) = source in 6900..6999 || SourceManager.DELEGATED_SOURCES.any {
|
||||
it.value.sourceId == source
|
||||
}
|
||||
|
||||
fun isEhSource(source: Long) = source == EH_SOURCE_ID
|
||||
|| source == EH_METADATA_SOURCE_ID
|
||||
|
85
app/src/main/java/exh/EXHMigrations.kt
Normal file
85
app/src/main/java/exh/EXHMigrations.kt
Normal file
@ -0,0 +1,85 @@
|
||||
package exh
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.resolvers.MangaUrlPutResolver
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
object EXHMigrations {
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private const val CURRENT_MIGRATION_VERSION = 1
|
||||
|
||||
/**
|
||||
* Performs a migration when the application is updated.
|
||||
*
|
||||
* @param preferences Preferences of the application.
|
||||
* @return true if a migration is performed, false otherwise.
|
||||
*/
|
||||
fun upgrade(preferences: PreferencesHelper): Boolean {
|
||||
val context = preferences.context
|
||||
val oldVersion = preferences.eh_lastVersionCode().getOrDefault()
|
||||
if (oldVersion < CURRENT_MIGRATION_VERSION) {
|
||||
preferences.eh_lastVersionCode().set(CURRENT_MIGRATION_VERSION)
|
||||
|
||||
if(oldVersion < 1) {
|
||||
db.inTransaction {
|
||||
// Migrate HentaiCafe source IDs
|
||||
db.lowLevel().executeSQL(RawQuery.builder()
|
||||
.query("""
|
||||
UPDATE ${MangaTable.TABLE}
|
||||
SET ${MangaTable.COL_SOURCE} = 260868874183818481
|
||||
WHERE ${MangaTable.COL_SOURCE} = $HENTAI_CAFE_SOURCE_ID
|
||||
""".trimIndent())
|
||||
.affectsTables(MangaTable.TABLE)
|
||||
.build())
|
||||
|
||||
// Migrate nhentai URLs
|
||||
val nhentaiManga = db.db.get()
|
||||
.listOfObjects(Manga::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(MangaTable.TABLE)
|
||||
.where("${MangaTable.COL_SOURCE} = $NHENTAI_SOURCE_ID")
|
||||
.build())
|
||||
.prepare()
|
||||
.executeAsBlocking()
|
||||
|
||||
nhentaiManga.forEach {
|
||||
it.url = getUrlWithoutDomain(it.url)
|
||||
}
|
||||
|
||||
db.db.put()
|
||||
.objects(nhentaiManga)
|
||||
// Extremely slow without the resolver :/
|
||||
.withPutResolver(MangaUrlPutResolver())
|
||||
.prepare()
|
||||
.executeAsBlocking()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun getUrlWithoutDomain(orig: String): String {
|
||||
return try {
|
||||
val uri = URI(orig)
|
||||
var out = uri.path
|
||||
if (uri.query != null)
|
||||
out += "?" + uri.query
|
||||
if (uri.fragment != null)
|
||||
out += "#" + uri.fragment
|
||||
out
|
||||
} catch (e: URISyntaxException) {
|
||||
orig
|
||||
}
|
||||
}
|
||||
}
|
@ -138,7 +138,7 @@ class GalleryAdder {
|
||||
|
||||
val cleanedUrl = when(source) {
|
||||
EH_SOURCE_ID, EXH_SOURCE_ID -> ExGalleryMetadata.normalizeUrl(getUrlWithoutDomain(realUrl))
|
||||
NHENTAI_SOURCE_ID -> realUrl //nhentai uses URLs directly (oops, my bad when implementing this source)
|
||||
NHENTAI_SOURCE_ID -> getUrlWithoutDomain(realUrl)
|
||||
PERV_EDEN_EN_SOURCE_ID,
|
||||
PERV_EDEN_IT_SOURCE_ID -> getUrlWithoutDomain(realUrl)
|
||||
HENTAI_CAFE_SOURCE_ID -> getUrlWithoutDomain(realUrl)
|
||||
|
38
app/src/main/java/exh/debug/DebugFunctions.kt
Normal file
38
app/src/main/java/exh/debug/DebugFunctions.kt
Normal file
@ -0,0 +1,38 @@
|
||||
package exh.debug
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.RawQuery
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
object DebugFunctions {
|
||||
val db: DatabaseHelper by injectLazy()
|
||||
val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
fun addAllMangaInDatabaseToLibrary() {
|
||||
db.inTransaction {
|
||||
db.lowLevel().executeSQL(RawQuery.builder()
|
||||
.query("""
|
||||
UPDATE ${MangaTable.TABLE}
|
||||
SET ${MangaTable.COL_FAVORITE} = 1
|
||||
""".trimIndent())
|
||||
.affectsTables(MangaTable.TABLE)
|
||||
.build())
|
||||
}
|
||||
}
|
||||
|
||||
fun countMangaInDatabaseInLibrary() = db.getMangas().executeAsBlocking().count { it.favorite }
|
||||
|
||||
fun countMangaInDatabaseNotInLibrary() = db.getMangas().executeAsBlocking().count { !it.favorite }
|
||||
|
||||
fun countMangaInDatabase() = db.getMangas().executeAsBlocking().size
|
||||
|
||||
fun countMetadataInDatabase() = db.getSearchMetadata().executeAsBlocking().size
|
||||
|
||||
fun countMangaInLibraryWithMissingMetadata() = db.getMangas().executeAsBlocking().count {
|
||||
it.favorite && db.getSearchMetadataForManga(it.id!!).executeAsBlocking() == null
|
||||
}
|
||||
|
||||
fun clearSavedSearches() = prefs.eh_savedSearches().set("")
|
||||
}
|
33
app/src/main/java/exh/debug/SettingsDebugController.kt
Normal file
33
app/src/main/java/exh/debug/SettingsDebugController.kt
Normal file
@ -0,0 +1,33 @@
|
||||
package exh.debug
|
||||
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import android.util.Log
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.ui.setting.SettingsController
|
||||
import eu.kanade.tachiyomi.ui.setting.onClick
|
||||
import eu.kanade.tachiyomi.ui.setting.preference
|
||||
import kotlin.reflect.full.declaredFunctions
|
||||
|
||||
class SettingsDebugController : SettingsController() {
|
||||
override fun setupPreferenceScreen(screen: PreferenceScreen) = with(screen) {
|
||||
title = "DEBUG MENU"
|
||||
|
||||
DebugFunctions::class.declaredFunctions.forEach {
|
||||
preference {
|
||||
title = it.name.replace(Regex("(.)(\\p{Upper})"), "$1 $2").toLowerCase().capitalize()
|
||||
isPersistent = false
|
||||
|
||||
onClick {
|
||||
try {
|
||||
val result = it.call(DebugFunctions)
|
||||
MaterialDialog.Builder(context)
|
||||
.content("Function returned result:\n\n$result")
|
||||
} catch(t: Throwable) {
|
||||
MaterialDialog.Builder(context)
|
||||
.content("Function threw exception:\n\n${Log.getStackTraceString(t)}")
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -87,12 +87,12 @@ class FavoritesSyncHelper(val context: Context) {
|
||||
ignore { wakeLock?.release() }
|
||||
wakeLock = ignore {
|
||||
context.powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
|
||||
"ExhFavoritesSyncWakelock")
|
||||
"teh:ExhFavoritesSyncWakelock")
|
||||
}
|
||||
ignore { wifiLock?.release() }
|
||||
wifiLock = ignore {
|
||||
context.wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL,
|
||||
"ExhFavoritesSyncWifi")
|
||||
"teh:ExhFavoritesSyncWifi")
|
||||
}
|
||||
|
||||
storage.getRealm().use { realm ->
|
||||
|
244
app/src/main/java/exh/hitomi/HitomiNozomi.kt
Normal file
244
app/src/main/java/exh/hitomi/HitomiNozomi.kt
Normal file
@ -0,0 +1,244 @@
|
||||
package exh.hitomi
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import exh.metadata.metadata.HitomiSearchMetadata.Companion.LTN_BASE_URL
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.vepta.vdm.ByteCursor
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import java.security.MessageDigest
|
||||
|
||||
private typealias HashedTerm = ByteArray
|
||||
|
||||
private data class DataPair(val offset: Long, val length: Int)
|
||||
private data class Node(val keys: List<ByteArray>,
|
||||
val datas: List<DataPair>,
|
||||
val subnodeAddresses: List<Long>)
|
||||
|
||||
/**
|
||||
* Kotlin port of the hitomi.la search algorithm
|
||||
* @author NerdNumber9
|
||||
*/
|
||||
class HitomiNozomi(private val client: OkHttpClient,
|
||||
private val tagIndexVersion: Long,
|
||||
private val galleriesIndexVersion: Long) {
|
||||
fun getGalleryIdsForQuery(query: String): Single<List<Int>> {
|
||||
val replacedQuery = query.replace('_', ' ')
|
||||
|
||||
if(':' in replacedQuery) {
|
||||
val sides = replacedQuery.split(':')
|
||||
val namespace = sides[0]
|
||||
var tag = sides[1]
|
||||
|
||||
var area: String? = namespace
|
||||
var language = "all"
|
||||
if(namespace == "female" || namespace == "male") {
|
||||
area = "tag"
|
||||
tag = replacedQuery
|
||||
} else if(namespace == "language") {
|
||||
area = null
|
||||
language = tag
|
||||
tag = "index"
|
||||
}
|
||||
|
||||
return getGalleryIdsFromNozomi(area, tag, language)
|
||||
}
|
||||
|
||||
val key = hashTerm(query)
|
||||
val field = "galleries"
|
||||
|
||||
return getNodeAtAddress(field, 0).flatMap { node ->
|
||||
if(node == null) {
|
||||
Single.just(null)
|
||||
} else {
|
||||
BSearch(field, key, node).flatMap { data ->
|
||||
if (data == null) {
|
||||
Single.just(null)
|
||||
} else {
|
||||
getGalleryIdsFromData(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGalleryIdsFromData(data: DataPair?): Single<List<Int>> {
|
||||
if(data == null)
|
||||
return Single.just(emptyList())
|
||||
|
||||
val url = "$LTN_BASE_URL/$GALLERIES_INDEX_DIR/galleries.$galleriesIndexVersion.data"
|
||||
val (offset, length) = data
|
||||
if(length > 100000000 || length <= 0)
|
||||
return Single.just(emptyList())
|
||||
|
||||
return client.newCall(rangedGet(url, offset, offset + length - 1))
|
||||
.asObservable()
|
||||
.map {
|
||||
it.body()?.bytes() ?: ByteArray(0)
|
||||
}
|
||||
.onErrorReturn { ByteArray(0) }
|
||||
.map { inbuf ->
|
||||
if(inbuf.isEmpty())
|
||||
return@map emptyList<Int>()
|
||||
|
||||
val view = ByteCursor(inbuf)
|
||||
val numberOfGalleryIds = view.nextInt()
|
||||
|
||||
val expectedLength = numberOfGalleryIds * 4 + 4
|
||||
|
||||
if(numberOfGalleryIds > 10000000
|
||||
|| numberOfGalleryIds <= 0
|
||||
|| inbuf.size != expectedLength) {
|
||||
return@map emptyList<Int>()
|
||||
}
|
||||
|
||||
(1 .. numberOfGalleryIds).map {
|
||||
view.nextInt()
|
||||
}
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
private fun BSearch(field: String, key: ByteArray, node: Node?): Single<DataPair?> {
|
||||
fun compareByteArrays(dv1: ByteArray, dv2: ByteArray): Int {
|
||||
val top = Math.min(dv1.size, dv2.size)
|
||||
for(i in 0 until top) {
|
||||
val dv1i = dv1[i].toInt() and 0xFF
|
||||
val dv2i = dv2[i].toInt() and 0xFF
|
||||
if(dv1i < dv2i)
|
||||
return -1
|
||||
else if(dv1i > dv2i)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
fun locateKey(key: ByteArray, node: Node): Pair<Boolean, Int> {
|
||||
var cmpResult = -1
|
||||
var lastI = 0
|
||||
for(nodeKey in node.keys) {
|
||||
cmpResult = compareByteArrays(key, nodeKey)
|
||||
if(cmpResult <= 0) break
|
||||
lastI++
|
||||
}
|
||||
return (cmpResult == 0) to lastI
|
||||
}
|
||||
|
||||
fun isLeaf(node: Node): Boolean {
|
||||
return !node.subnodeAddresses.any {
|
||||
it != 0L
|
||||
}
|
||||
}
|
||||
|
||||
if(node == null || node.keys.isEmpty()) {
|
||||
return Single.just(null)
|
||||
}
|
||||
|
||||
val (there, where) = locateKey(key, node)
|
||||
if(there) {
|
||||
return Single.just(node.datas[where])
|
||||
} else if(isLeaf(node)) {
|
||||
return Single.just(null)
|
||||
}
|
||||
|
||||
return getNodeAtAddress(field, node.subnodeAddresses[where]).flatMap { newNode ->
|
||||
BSearch(field, key, newNode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeNode(data: ByteArray): Node {
|
||||
val view = ByteCursor(data)
|
||||
|
||||
val numberOfKeys = view.nextInt()
|
||||
|
||||
val keys = (1 .. numberOfKeys).map {
|
||||
val keySize = view.nextInt()
|
||||
view.next(keySize)
|
||||
}
|
||||
|
||||
val numberOfDatas = view.nextInt()
|
||||
val datas = (1 .. numberOfDatas).map {
|
||||
val offset = view.nextLong()
|
||||
val length = view.nextInt()
|
||||
DataPair(offset, length)
|
||||
}
|
||||
|
||||
val numberOfSubnodeAddresses = B + 1
|
||||
val subnodeAddresses = (1 .. numberOfSubnodeAddresses).map {
|
||||
view.nextLong()
|
||||
}
|
||||
|
||||
return Node(keys, datas, subnodeAddresses)
|
||||
}
|
||||
|
||||
private fun getNodeAtAddress(field: String, address: Long): Single<Node?> {
|
||||
var url = "$LTN_BASE_URL/$INDEX_DIR/$field.$tagIndexVersion.index"
|
||||
if(field == "galleries") {
|
||||
url = "$LTN_BASE_URL/$GALLERIES_INDEX_DIR/galleries.$galleriesIndexVersion.index"
|
||||
}
|
||||
|
||||
return client.newCall(rangedGet(url, address, address + MAX_NODE_SIZE - 1))
|
||||
.asObservableSuccess()
|
||||
.map {
|
||||
it.body()?.bytes() ?: ByteArray(0)
|
||||
}
|
||||
.onErrorReturn { ByteArray(0) }
|
||||
.map { nodedata ->
|
||||
if(nodedata.isNotEmpty()) {
|
||||
decodeNode(nodedata)
|
||||
} else null
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
fun getGalleryIdsFromNozomi(area: String?, tag: String, language: String): Single<List<Int>> {
|
||||
var nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$tag-$language$NOZOMI_EXTENSION"
|
||||
if(area != null) {
|
||||
nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$area/$tag-$language$NOZOMI_EXTENSION"
|
||||
}
|
||||
|
||||
return client.newCall(Request.Builder()
|
||||
.url(nozomiAddress)
|
||||
.build())
|
||||
.asObservableSuccess()
|
||||
.map { resp ->
|
||||
val body = resp.body()!!.bytes()
|
||||
val cursor = ByteCursor(body)
|
||||
(1 .. body.size / 4).map {
|
||||
cursor.nextInt()
|
||||
}
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
private fun hashTerm(query: String): HashedTerm {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
md.update(query.toByteArray(HASH_CHARSET))
|
||||
return md.digest().copyOf(4)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INDEX_DIR = "tagindex"
|
||||
private const val GALLERIES_INDEX_DIR = "galleriesindex"
|
||||
private const val COMPRESSED_NOZOMI_PREFIX = "n"
|
||||
private const val NOZOMI_EXTENSION = ".nozomi"
|
||||
private const val MAX_NODE_SIZE = 464
|
||||
private const val B = 16
|
||||
|
||||
private val HASH_CHARSET = Charsets.UTF_8
|
||||
|
||||
fun rangedGet(url: String, rangeBegin: Long, rangeEnd: Long?): Request {
|
||||
return GET(url, Headers.Builder()
|
||||
.add("Range", "bytes=$rangeBegin-${rangeEnd ?: ""}")
|
||||
.build())
|
||||
}
|
||||
|
||||
|
||||
fun getIndexVersion(httpClient: OkHttpClient, name: String): Observable<Long> {
|
||||
return httpClient.newCall(GET("$LTN_BASE_URL/$name/version?_=${System.currentTimeMillis()}"))
|
||||
.asObservableSuccess()
|
||||
.map { it.body()!!.string().toLong() }
|
||||
}
|
||||
}
|
||||
}
|
@ -14,21 +14,21 @@ import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun Realm.loadAllMetadata(): Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>> =
|
||||
Injekt.get<SourceManager>().getOnlineSources().filterIsInstance<LewdSource<*, *>>().map {
|
||||
it.queryAll()
|
||||
}.associate {
|
||||
it.clazz to it.query(this@loadAllMetadata).sort(SearchableGalleryMetadata::mangaId.name).findAll()
|
||||
}.toMap()
|
||||
//fun Realm.loadAllMetadata(): Map<KClass<out SearchableGalleryMetadata>, RealmResults<out SearchableGalleryMetadata>> =
|
||||
// Injekt.get<SourceManager>().getOnlineSources().filterIsInstance<LewdSource<*, *>>().map {
|
||||
// it.queryAll()
|
||||
// }.associate {
|
||||
// it.clazz to it.query(this@loadAllMetadata).sort(SearchableGalleryMetadata::mangaId.name).findAll()
|
||||
// }.toMap()
|
||||
|
||||
fun Realm.queryMetadataFromManga(manga: Manga,
|
||||
meta: RealmQuery<SearchableGalleryMetadata>? = null):
|
||||
RealmQuery<out SearchableGalleryMetadata> =
|
||||
Injekt.get<SourceManager>().get(manga.source)?.let {
|
||||
(it as LewdSource<*, *>).queryFromUrl(manga.url) as GalleryQuery<SearchableGalleryMetadata>
|
||||
}?.query(this, meta) ?: throw IllegalArgumentException("Unknown source type!")
|
||||
//fun Realm.queryMetadataFromManga(manga: Manga,
|
||||
// meta: RealmQuery<SearchableGalleryMetadata>? = null):
|
||||
// RealmQuery<out SearchableGalleryMetadata> =
|
||||
// Injekt.get<SourceManager>().get(manga.source)?.let {
|
||||
// (it as LewdSource<*, *>).queryFromUrl(manga.url) as GalleryQuery<SearchableGalleryMetadata>
|
||||
// }?.query(this, meta) ?: throw IllegalArgumentException("Unknown source type!")
|
||||
|
||||
fun Realm.syncMangaIds(mangas: List<LibraryItem>) {
|
||||
/*fun Realm.syncMangaIds(mangas: List<LibraryItem>) {
|
||||
Timber.d("--> EH: Begin syncing ${mangas.size} manga IDs...")
|
||||
executeTransaction {
|
||||
mangas.forEach { manga ->
|
||||
@ -46,7 +46,7 @@ fun Realm.syncMangaIds(mangas: List<LibraryItem>) {
|
||||
}
|
||||
}
|
||||
Timber.d("--> EH: Finish syncing ${mangas.size} manga IDs!")
|
||||
}
|
||||
}*/
|
||||
|
||||
val Manga.metadataClass
|
||||
get() = (Injekt.get<SourceManager>().get(source) as? LewdSource<*, *>)?.queryAll()?.clazz
|
||||
//val Manga.metadataClass
|
||||
// get() = (Injekt.get<SourceManager>().get(source) as? LewdSource<*, *>)?.queryAll()?.clazz
|
||||
|
135
app/src/main/java/exh/metadata/metadata/EHentaiSearchMetadata.kt
Normal file
135
app/src/main/java/exh/metadata/metadata/EHentaiSearchMetadata.kt
Normal file
@ -0,0 +1,135 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.*
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
|
||||
class EHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
var gId: String?
|
||||
get() = indexedExtra
|
||||
set(value) { indexedExtra = value }
|
||||
|
||||
var gToken: String? = null
|
||||
var exh: Boolean? = null
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_TITLE)
|
||||
var altTitle by titleDelegate(TITLE_TYPE_ALT_TITLE)
|
||||
|
||||
var genre: String? = null
|
||||
|
||||
var datePosted: Long? = null
|
||||
var parent: String? = null
|
||||
var visible: String? = null //Not a boolean
|
||||
var language: String? = null
|
||||
var translated: Boolean? = null
|
||||
var size: Long? = null
|
||||
var length: Int? = null
|
||||
var favorites: Int? = null
|
||||
var ratingCount: Int? = null
|
||||
var averageRating: Double? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
gId?.let { gId ->
|
||||
gToken?.let { gToken ->
|
||||
manga.url = idAndTokenToUrl(gId, gToken)
|
||||
}
|
||||
}
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
//No title bug?
|
||||
val titleObj = if(Injekt.get<PreferencesHelper>().useJapaneseTitle().getOrDefault())
|
||||
altTitle ?: title
|
||||
else
|
||||
title
|
||||
titleObj?.let { manga.title = it }
|
||||
|
||||
//Set artist (if we can find one)
|
||||
tags.filter { it.namespace == EH_ARTIST_NAMESPACE }.let {
|
||||
if(it.isNotEmpty()) manga.artist = it.joinToString(transform = { it.name })
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
//Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
|
||||
//We default to completed
|
||||
manga.status = SManga.COMPLETED
|
||||
title?.let { t ->
|
||||
ONGOING_SUFFIX.find {
|
||||
t.endsWith(it, ignoreCase = true)
|
||||
}?.let {
|
||||
manga.status = SManga.ONGOING
|
||||
}
|
||||
}
|
||||
|
||||
//Build a nice looking description out of what we know
|
||||
val titleDesc = StringBuilder()
|
||||
title?.let { titleDesc += "Title: $it\n" }
|
||||
altTitle?.let { titleDesc += "Alternate Title: $it\n" }
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
genre?.let { detailsDesc += "Genre: $it\n" }
|
||||
uploader?.let { detailsDesc += "Uploader: $it\n" }
|
||||
datePosted?.let { detailsDesc += "Posted: ${EX_DATE_FORMAT.format(Date(it))}\n" }
|
||||
visible?.let { detailsDesc += "Visible: $it\n" }
|
||||
language?.let {
|
||||
detailsDesc += "Language: $it"
|
||||
if(translated == true) detailsDesc += " TR"
|
||||
detailsDesc += "\n"
|
||||
}
|
||||
size?.let { detailsDesc += "File size: ${humanReadableByteCount(it, true)}\n" }
|
||||
length?.let { detailsDesc += "Length: $it pages\n" }
|
||||
favorites?.let { detailsDesc += "Favorited: $it times\n" }
|
||||
averageRating?.let {
|
||||
detailsDesc += "Rating: $it"
|
||||
ratingCount?.let { detailsDesc += " ($it)" }
|
||||
detailsDesc += "\n"
|
||||
}
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_TITLE = 0
|
||||
private const val TITLE_TYPE_ALT_TITLE = 1
|
||||
|
||||
const val TAG_TYPE_NORMAL = 0
|
||||
const val TAG_TYPE_LIGHT = 1
|
||||
|
||||
const val EH_GENRE_NAMESPACE = "genre"
|
||||
private const val EH_ARTIST_NAMESPACE = "artist"
|
||||
|
||||
private fun splitGalleryUrl(url: String)
|
||||
= url.let {
|
||||
//Only parse URL if is full URL
|
||||
val pathSegments = if(it.startsWith("http"))
|
||||
Uri.parse(it).pathSegments
|
||||
else
|
||||
it.split('/')
|
||||
pathSegments.filterNot(String::isNullOrBlank)
|
||||
}
|
||||
|
||||
fun galleryId(url: String) = splitGalleryUrl(url)[1]
|
||||
|
||||
fun galleryToken(url: String) =
|
||||
splitGalleryUrl(url)[2]
|
||||
|
||||
fun normalizeUrl(url: String)
|
||||
= idAndTokenToUrl(galleryId(url), galleryToken(url))
|
||||
|
||||
fun idAndTokenToUrl(id: String, token: String)
|
||||
= "/g/$id/$token/?nw=always"
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
|
||||
class HentaiCafeSearchMetadata : RaisedSearchMetadata() {
|
||||
var hcId: String? = null
|
||||
var readerId: String? = null
|
||||
|
||||
var url get() = hcId?.let { "$BASE_URL/$it" }
|
||||
set(a) {
|
||||
a?.let {
|
||||
hcId = hcIdFromUrl(a)
|
||||
}
|
||||
}
|
||||
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var artist: String? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
manga.title = title!!
|
||||
manga.artist = artist
|
||||
manga.author = artist
|
||||
|
||||
//Not available
|
||||
manga.status = SManga.UNKNOWN
|
||||
|
||||
val detailsDesc = "Title: $title\n" +
|
||||
"Artist: $artist\n"
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
manga.description = listOf(detailsDesc, tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val BASE_URL = "https://hentai.cafe"
|
||||
|
||||
fun hcIdFromUrl(url: String)
|
||||
= url.split("/").last { it.isNotBlank() }
|
||||
}
|
||||
}
|
102
app/src/main/java/exh/metadata/metadata/HitomiSearchMetadata.kt
Normal file
102
app/src/main/java/exh/metadata/metadata/HitomiSearchMetadata.kt
Normal file
@ -0,0 +1,102 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.EX_DATE_FORMAT
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import java.util.*
|
||||
|
||||
class HitomiSearchMetadata: RaisedSearchMetadata() {
|
||||
var url get() = hlId?.let { urlFromHlId(it) }
|
||||
set(a) {
|
||||
a?.let {
|
||||
hlId = hlIdFromUrl(a)
|
||||
}
|
||||
}
|
||||
|
||||
var hlId: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var artists: List<String> = emptyList()
|
||||
|
||||
var group: String? = null
|
||||
|
||||
var type: String? = null
|
||||
|
||||
var language: String? = null
|
||||
|
||||
var series: List<String> = emptyList()
|
||||
|
||||
var characters: List<String> = emptyList()
|
||||
|
||||
var uploadDate: Long? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
|
||||
title?.let {
|
||||
manga.title = it
|
||||
titleDesc += "Title: $it\n"
|
||||
}
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
|
||||
manga.artist = artists.joinToString()
|
||||
|
||||
detailsDesc += "Artist(s): ${manga.artist}\n"
|
||||
|
||||
group?.let {
|
||||
detailsDesc += "Group: $it\n"
|
||||
}
|
||||
|
||||
type?.let {
|
||||
detailsDesc += "Type: ${it.capitalize()}\n"
|
||||
}
|
||||
|
||||
(language ?: "unknown").let {
|
||||
detailsDesc += "Language: ${it.capitalize()}\n"
|
||||
}
|
||||
|
||||
if(series.isNotEmpty())
|
||||
detailsDesc += "Series: ${series.joinToString()}\n"
|
||||
|
||||
if(characters.isNotEmpty())
|
||||
detailsDesc += "Characters: ${characters.joinToString()}\n"
|
||||
|
||||
uploadDate?.let {
|
||||
detailsDesc += "Upload date: ${EX_DATE_FORMAT.format(Date(it))}\n"
|
||||
}
|
||||
|
||||
manga.status = SManga.UNKNOWN
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val LTN_BASE_URL = "https://ltn.hitomi.la"
|
||||
val BASE_URL = "https://hitomi.la"
|
||||
val IMG_BASE_URL = "https://aa.hitomi.la/galleries"
|
||||
|
||||
fun hlIdFromUrl(url: String)
|
||||
= url.split('/').last().substringBeforeLast('.')
|
||||
|
||||
fun urlFromHlId(id: String)
|
||||
= "$BASE_URL/galleries/$id.html"
|
||||
}
|
||||
}
|
120
app/src/main/java/exh/metadata/metadata/NHentaiSearchMetadata.kt
Normal file
120
app/src/main/java/exh/metadata/metadata/NHentaiSearchMetadata.kt
Normal file
@ -0,0 +1,120 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.*
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
import java.util.*
|
||||
|
||||
class NHentaiSearchMetadata : RaisedSearchMetadata() {
|
||||
var url get() = nhId?.let { BASE_URL + nhIdToPath(it) }
|
||||
set(a) {
|
||||
a?.let {
|
||||
nhId = nhUrlToId(a)
|
||||
}
|
||||
}
|
||||
|
||||
var nhId: Long? = null
|
||||
|
||||
var uploadDate: Long? = null
|
||||
|
||||
var favoritesCount: Long? = null
|
||||
|
||||
var mediaId: String? = null
|
||||
|
||||
var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE)
|
||||
var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH)
|
||||
var shortTitle by titleDelegate(TITLE_TYPE_SHORT)
|
||||
|
||||
var coverImageType: String? = null
|
||||
var pageImageTypes: List<String> = emptyList()
|
||||
var thumbnailImageType: String? = null
|
||||
|
||||
var scanlator: String? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
nhId?.let { manga.url = nhIdToPath(it) }
|
||||
|
||||
if(mediaId != null) {
|
||||
val hqThumbs = Injekt.get<PreferencesHelper>().eh_nh_useHighQualityThumbs().getOrDefault()
|
||||
typeToExtension(if(hqThumbs) coverImageType else thumbnailImageType)?.let {
|
||||
manga.thumbnail_url = "https://t.nhentai.net/galleries/$mediaId/${if(hqThumbs)
|
||||
"cover"
|
||||
else "thumb"}.$it"
|
||||
}
|
||||
}
|
||||
|
||||
manga.title = englishTitle ?: japaneseTitle ?: shortTitle!!
|
||||
|
||||
//Set artist (if we can find one)
|
||||
tags.filter { it.namespace == NHENTAI_ARTIST_NAMESPACE }.let {
|
||||
if(it.isNotEmpty()) manga.artist = it.joinToString(transform = { it.name })
|
||||
}
|
||||
|
||||
var category: String? = null
|
||||
tags.filter { it.namespace == NHENTAI_CATEGORIES_NAMESPACE }.let {
|
||||
if(it.isNotEmpty()) category = it.joinToString(transform = { it.name })
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
//Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes
|
||||
//We default to completed
|
||||
manga.status = SManga.COMPLETED
|
||||
englishTitle?.let { t ->
|
||||
ONGOING_SUFFIX.find {
|
||||
t.endsWith(it, ignoreCase = true)
|
||||
}?.let {
|
||||
manga.status = SManga.ONGOING
|
||||
}
|
||||
}
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
englishTitle?.let { titleDesc += "English Title: $it\n" }
|
||||
japaneseTitle?.let { titleDesc += "Japanese Title: $it\n" }
|
||||
shortTitle?.let { titleDesc += "Short Title: $it\n" }
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
category?.let { detailsDesc += "Category: $it\n" }
|
||||
uploadDate?.let { detailsDesc += "Upload Date: ${EX_DATE_FORMAT.format(Date(it * 1000))}\n" }
|
||||
pageImageTypes.size.let { detailsDesc += "Length: $it pages\n" }
|
||||
favoritesCount?.let { detailsDesc += "Favorited: $it times\n" }
|
||||
scanlator?.nullIfBlank()?.let { detailsDesc += "Scanlator: $it\n" }
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_JAPANESE = 0
|
||||
private const val TITLE_TYPE_ENGLISH = 1
|
||||
private const val TITLE_TYPE_SHORT = 2
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val BASE_URL = "https://nhentai.net"
|
||||
|
||||
private const val NHENTAI_ARTIST_NAMESPACE = "artist"
|
||||
private const val NHENTAI_CATEGORIES_NAMESPACE = "category"
|
||||
|
||||
fun typeToExtension(t: String?) =
|
||||
when(t) {
|
||||
"p" -> "png"
|
||||
"j" -> "jpg"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun nhUrlToId(url: String)
|
||||
= url.split("/").last { it.isNotBlank() }.toLong()
|
||||
|
||||
fun nhIdToPath(id: Long) = "/g/$id/"
|
||||
}
|
||||
}
|
@ -0,0 +1,108 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.PERV_EDEN_EN_SOURCE_ID
|
||||
import exh.PERV_EDEN_IT_SOURCE_ID
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.metadata.metadata.base.RaisedTitle
|
||||
import exh.plusAssign
|
||||
|
||||
class PervEdenSearchMetadata : RaisedSearchMetadata() {
|
||||
var pvId: String? = null
|
||||
|
||||
var url: String? = null
|
||||
var thumbnailUrl: String? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
var altTitles
|
||||
get() = titles.filter { it.type == TITLE_TYPE_ALT }.map { it.title }
|
||||
set(value) {
|
||||
titles.removeAll { it.type == TITLE_TYPE_ALT }
|
||||
titles += value.map { RaisedTitle(it, TITLE_TYPE_ALT) }
|
||||
}
|
||||
|
||||
var artist: String? = null
|
||||
|
||||
var type: String? = null
|
||||
|
||||
var rating: Float? = null
|
||||
|
||||
var status: String? = null
|
||||
|
||||
var lang: String? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
url?.let { manga.url = it }
|
||||
thumbnailUrl?.let { manga.thumbnail_url = it }
|
||||
|
||||
val titleDesc = StringBuilder()
|
||||
title?.let {
|
||||
manga.title = it
|
||||
titleDesc += "Title: $it\n"
|
||||
}
|
||||
if(altTitles.isNotEmpty())
|
||||
titleDesc += "Alternate Titles: \n" + altTitles.map {
|
||||
"▪ $it"
|
||||
}.joinToString(separator = "\n", postfix = "\n")
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
artist?.let {
|
||||
manga.artist = it
|
||||
detailsDesc += "Artist: $it\n"
|
||||
}
|
||||
|
||||
type?.let {
|
||||
detailsDesc += "Type: $it\n"
|
||||
}
|
||||
|
||||
status?.let {
|
||||
manga.status = when(it) {
|
||||
"Ongoing" -> SManga.ONGOING
|
||||
"Completed", "Suspended" -> SManga.COMPLETED
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
detailsDesc += "Status: $it\n"
|
||||
}
|
||||
|
||||
rating?.let {
|
||||
detailsDesc += "Rating: %.2\n".format(it)
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
private const val TITLE_TYPE_ALT = 1
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
private fun splitGalleryUrl(url: String)
|
||||
= url.let {
|
||||
Uri.parse(it).pathSegments.filterNot(String::isNullOrBlank)
|
||||
}
|
||||
|
||||
fun pvIdFromUrl(url: String) = splitGalleryUrl(url).last()
|
||||
}
|
||||
}
|
||||
|
||||
enum class PervEdenLang(val id: Long) {
|
||||
//DO NOT RENAME THESE TO CAPITAL LETTERS! The enum names are used to build URLs
|
||||
en(PERV_EDEN_EN_SOURCE_ID),
|
||||
it(PERV_EDEN_IT_SOURCE_ID);
|
||||
|
||||
companion object {
|
||||
fun source(id: Long)
|
||||
= PervEdenLang.values().find { it.id == id }
|
||||
?: throw IllegalArgumentException("Unknown source ID: $id!")
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.net.Uri
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.EX_DATE_FORMAT
|
||||
import exh.metadata.buildTagsDescription
|
||||
import exh.metadata.joinEmulatedTagsToGenreString
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import exh.plusAssign
|
||||
import java.util.*
|
||||
|
||||
class TsuminoSearchMetadata : RaisedSearchMetadata() {
|
||||
var tmId: Int? = null
|
||||
|
||||
var title by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var artist: String? = null
|
||||
|
||||
var uploadDate: Long? = null
|
||||
|
||||
var length: Int? = null
|
||||
|
||||
var ratingString: String? = null
|
||||
|
||||
var category: String? = null
|
||||
|
||||
var collection: String? = null
|
||||
|
||||
var group: String? = null
|
||||
|
||||
var parody: List<String> = emptyList()
|
||||
|
||||
var character: List<String> = emptyList()
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
title?.let { manga.title = it }
|
||||
manga.thumbnail_url = BASE_URL + thumbUrlFromId(tmId.toString())
|
||||
|
||||
artist?.let { manga.artist = it }
|
||||
|
||||
manga.status = SManga.UNKNOWN
|
||||
|
||||
val titleDesc = "Title: $title\n"
|
||||
|
||||
val detailsDesc = StringBuilder()
|
||||
uploader?.let { detailsDesc += "Uploader: $it\n" }
|
||||
uploadDate?.let { detailsDesc += "Uploaded: ${EX_DATE_FORMAT.format(Date(it))}\n" }
|
||||
length?.let { detailsDesc += "Length: $it pages\n" }
|
||||
ratingString?.let { detailsDesc += "Rating: $it\n" }
|
||||
category?.let {
|
||||
detailsDesc += "Category: $it\n"
|
||||
}
|
||||
collection?.let { detailsDesc += "Collection: $it\n" }
|
||||
group?.let { detailsDesc += "Group: $it\n" }
|
||||
val parodiesString = parody.joinToString()
|
||||
if(parodiesString.isNotEmpty()) {
|
||||
detailsDesc += "Parody: $parodiesString\n"
|
||||
}
|
||||
val charactersString = character.joinToString()
|
||||
if(charactersString.isNotEmpty()) {
|
||||
detailsDesc += "Character: $charactersString\n"
|
||||
}
|
||||
|
||||
//Copy tags -> genres
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
val tagsDesc = tagsToDescription()
|
||||
|
||||
manga.description = listOf(titleDesc, detailsDesc.toString(), tagsDesc.toString())
|
||||
.filter(String::isNotBlank)
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
|
||||
val BASE_URL = "https://www.tsumino.com"
|
||||
|
||||
fun tmIdFromUrl(url: String)
|
||||
= Uri.parse(url).pathSegments[2]
|
||||
|
||||
fun mangaUrlFromId(id: String) = "/Book/Info/$id"
|
||||
|
||||
fun thumbUrlFromId(id: String) = "/Image/Thumb/$id"
|
||||
}
|
||||
}
|
91
app/src/main/java/exh/metadata/metadata/base/FlatMetadata.kt
Normal file
91
app/src/main/java/exh/metadata/metadata/base/FlatMetadata.kt
Normal file
@ -0,0 +1,91 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
import com.pushtorefresh.storio.operations.PreparedOperation
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import rx.Completable
|
||||
import rx.Single
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
data class FlatMetadata(
|
||||
val metadata: SearchMetadata,
|
||||
val tags: List<SearchTag>,
|
||||
val titles: List<SearchTitle>
|
||||
) {
|
||||
inline fun <reified T : RaisedSearchMetadata> raise(): T = raise(T::class)
|
||||
|
||||
fun <T : RaisedSearchMetadata> raise(clazz: KClass<T>)
|
||||
= RaisedSearchMetadata.raiseFlattenGson
|
||||
.fromJson(metadata.extra, clazz.java).apply {
|
||||
fillBaseFields(this@FlatMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
fun DatabaseHelper.getFlatMetadataForManga(mangaId: Long): PreparedOperation<FlatMetadata?> {
|
||||
fun getSingle() = getSearchMetadataForManga(mangaId).asRxSingle().flatMap { meta ->
|
||||
if(meta == null) Single.just(null)
|
||||
else Single.zip(
|
||||
getSearchTagsForManga(mangaId).asRxSingle(),
|
||||
getSearchTitlesForManga(mangaId).asRxSingle()
|
||||
) { tags, titles ->
|
||||
FlatMetadata(meta, tags, titles)
|
||||
}
|
||||
}
|
||||
|
||||
return object : PreparedOperation<FlatMetadata?> {
|
||||
/**
|
||||
* Creates [rx.Observable] that emits result of Operation.
|
||||
*
|
||||
*
|
||||
* Observable may be "Hot" or "Cold", please read documentation of the concrete implementation.
|
||||
*
|
||||
* @return observable result of operation with only one [rx.Observer.onNext] call.
|
||||
*/
|
||||
override fun createObservable() = getSingle().toObservable()
|
||||
|
||||
/**
|
||||
* Executes operation synchronously in current thread.
|
||||
*
|
||||
*
|
||||
* Notice: Blocking I/O operation should not be executed on the Main Thread,
|
||||
* it can cause ANR (Activity Not Responding dialog), block the UI and drop animations frames.
|
||||
* So please, execute blocking I/O operation only from background thread.
|
||||
* See [WorkerThread].
|
||||
*
|
||||
* @return nullable result of operation.
|
||||
*/
|
||||
override fun executeAsBlocking() = getSingle().toBlocking().value()
|
||||
|
||||
/**
|
||||
* Creates [rx.Observable] that emits result of Operation.
|
||||
*
|
||||
*
|
||||
* Observable may be "Hot" (usually "Warm") or "Cold", please read documentation of the concrete implementation.
|
||||
*
|
||||
* @return observable result of operation with only one [rx.Observer.onNext] call.
|
||||
*/
|
||||
override fun asRxObservable() = getSingle().toObservable()
|
||||
|
||||
/**
|
||||
* Creates [rx.Single] that emits result of Operation lazily when somebody subscribes to it.
|
||||
*
|
||||
*
|
||||
*
|
||||
* @return single result of operation.
|
||||
*/
|
||||
override fun asRxSingle() = getSingle()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun DatabaseHelper.insertFlatMetadata(flatMetadata: FlatMetadata) = Completable.fromCallable {
|
||||
require(flatMetadata.metadata.mangaId != -1L)
|
||||
|
||||
inTransaction {
|
||||
insertSearchMetadata(flatMetadata.metadata).executeAsBlocking()
|
||||
setSearchTagsForManga(flatMetadata.metadata.mangaId, flatMetadata.tags)
|
||||
setSearchTitlesForManga(flatMetadata.metadata.mangaId, flatMetadata.titles)
|
||||
}
|
||||
}
|
@ -0,0 +1,137 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
import com.google.gson.GsonBuilder
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.metadata.forEach
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.plusAssign
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
abstract class RaisedSearchMetadata {
|
||||
@Transient
|
||||
var mangaId: Long = -1
|
||||
|
||||
@Transient
|
||||
var uploader: String? = null
|
||||
|
||||
@Transient
|
||||
protected open var indexedExtra: String? = null
|
||||
|
||||
@Transient
|
||||
val tags = mutableListOf<RaisedTag>()
|
||||
|
||||
@Transient
|
||||
val titles = mutableListOf<RaisedTitle>()
|
||||
|
||||
fun getTitleOfType(type: Int): String? = titles.find { it.type == type }?.title
|
||||
|
||||
fun replaceTitleOfType(type: Int, newTitle: String?) {
|
||||
titles.removeAll { it.type == type }
|
||||
if(newTitle != null) titles += RaisedTitle(newTitle, type)
|
||||
}
|
||||
|
||||
abstract fun copyTo(manga: SManga)
|
||||
|
||||
fun tagsToGenreString()
|
||||
= tags.filter { it.type != TAG_TYPE_VIRTUAL }
|
||||
.joinToString { (if(it.namespace != null) "${it.namespace}: " else "") + it.name }
|
||||
|
||||
fun tagsToDescription()
|
||||
= StringBuilder("Tags:\n").apply {
|
||||
//BiConsumer only available in Java 8, don't bother calling forEach directly on 'tags'
|
||||
val groupedTags = tags.filter { it.type != TAG_TYPE_VIRTUAL }.groupBy {
|
||||
it.namespace
|
||||
}.entries
|
||||
|
||||
groupedTags.forEach { namespace, tags ->
|
||||
if (tags.isNotEmpty()) {
|
||||
val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" })
|
||||
if(namespace != null) {
|
||||
this += "▪ "
|
||||
this += namespace
|
||||
this += ": "
|
||||
}
|
||||
this += joinedTags
|
||||
this += "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun flatten(): FlatMetadata {
|
||||
require(mangaId != -1L)
|
||||
|
||||
val extra = raiseFlattenGson.toJson(this)
|
||||
return FlatMetadata(
|
||||
SearchMetadata(
|
||||
mangaId,
|
||||
uploader,
|
||||
extra,
|
||||
indexedExtra,
|
||||
0
|
||||
),
|
||||
tags.map {
|
||||
SearchTag(
|
||||
null,
|
||||
mangaId,
|
||||
it.namespace,
|
||||
it.name,
|
||||
it.type
|
||||
)
|
||||
},
|
||||
titles.map {
|
||||
SearchTitle(
|
||||
null,
|
||||
mangaId,
|
||||
it.title,
|
||||
it.type
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun fillBaseFields(metadata: FlatMetadata) {
|
||||
mangaId = metadata.metadata.mangaId
|
||||
uploader = metadata.metadata.uploader
|
||||
indexedExtra = metadata.metadata.indexedExtra
|
||||
|
||||
this.tags.clear()
|
||||
this.tags += metadata.tags.map {
|
||||
RaisedTag(it.namespace, it.name, it.type)
|
||||
}
|
||||
|
||||
this.titles.clear()
|
||||
this.titles += metadata.titles.map {
|
||||
RaisedTitle(it.title, it.type)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Virtual tags allow searching of otherwise unindexed fields
|
||||
const val TAG_TYPE_VIRTUAL = -2
|
||||
|
||||
val raiseFlattenGson = GsonBuilder().create()
|
||||
|
||||
fun titleDelegate(type: Int) = object : ReadWriteProperty<RaisedSearchMetadata, String?> {
|
||||
/**
|
||||
* Returns the value of the property for the given object.
|
||||
* @param thisRef the object for which the value is requested.
|
||||
* @param property the metadata for the property.
|
||||
* @return the property value.
|
||||
*/
|
||||
override fun getValue(thisRef: RaisedSearchMetadata, property: KProperty<*>)
|
||||
= thisRef.getTitleOfType(type)
|
||||
|
||||
/**
|
||||
* Sets the value of the property for the given object.
|
||||
* @param thisRef the object for which the value is requested.
|
||||
* @param property the metadata for the property.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
override fun setValue(thisRef: RaisedSearchMetadata, property: KProperty<*>, value: String?)
|
||||
= thisRef.replaceTitleOfType(type, value)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
data class RaisedTag(val namespace: String?,
|
||||
val name: String,
|
||||
val type: Int)
|
@ -0,0 +1,6 @@
|
||||
package exh.metadata.metadata.base
|
||||
|
||||
data class RaisedTitle(
|
||||
val title: String,
|
||||
val type: Int = 0
|
||||
)
|
@ -29,7 +29,7 @@ open class NHentaiMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
var url get() = nhId?.let { "$BASE_URL/g/$it" }
|
||||
set(a) {
|
||||
a?.let {
|
||||
nhId = nhIdFromUrl(a)
|
||||
nhId = nhUrlToId(a)
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ open class NHentaiMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
val url: String
|
||||
) : GalleryQuery<NHentaiMetadata>(NHentaiMetadata::class) {
|
||||
override fun transform() = Query(
|
||||
nhIdFromUrl(url)
|
||||
nhUrlToId(url)
|
||||
)
|
||||
}
|
||||
|
||||
@ -154,7 +154,7 @@ open class NHentaiMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun nhIdFromUrl(url: String)
|
||||
fun nhUrlToId(url: String)
|
||||
= url.split("/").last { it.isNotBlank() }.toLong()
|
||||
|
||||
val TITLE_FIELDS = listOf(
|
||||
|
@ -104,29 +104,6 @@ open class PervEdenGalleryMetadata : RealmObject(), SearchableGalleryMetadata {
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
class EmptyQuery : GalleryQuery<PervEdenGalleryMetadata>(PervEdenGalleryMetadata::class)
|
||||
|
||||
class UrlQuery(
|
||||
val url: String,
|
||||
val lang: PervEdenLang
|
||||
) : GalleryQuery<PervEdenGalleryMetadata>(PervEdenGalleryMetadata::class) {
|
||||
override fun transform() = Query(
|
||||
pvIdFromUrl(url),
|
||||
lang
|
||||
)
|
||||
}
|
||||
|
||||
class Query(val pvId: String,
|
||||
val lang: PervEdenLang
|
||||
) : GalleryQuery<PervEdenGalleryMetadata>(PervEdenGalleryMetadata::class) {
|
||||
override fun map() = mapOf(
|
||||
PervEdenGalleryMetadata::pvId to Query::pvId
|
||||
)
|
||||
|
||||
override fun override(meta: RealmQuery<PervEdenGalleryMetadata>)
|
||||
= meta.equalTo(PervEdenGalleryMetadata::lang.name, lang.name)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun splitGalleryUrl(url: String)
|
||||
= url.let {
|
||||
@ -165,15 +142,3 @@ open class PervEdenTitle(var metadata: PervEdenGalleryMetadata? = null,
|
||||
|
||||
override fun toString() = "PervEdenTitle(metadata=$metadata, title=$title)"
|
||||
}
|
||||
|
||||
enum class PervEdenLang(val id: Long) {
|
||||
//DO NOT RENAME THESE TO CAPITAL LETTERS! The enum names are used to build URLs
|
||||
en(PERV_EDEN_EN_SOURCE_ID),
|
||||
it(PERV_EDEN_IT_SOURCE_ID);
|
||||
|
||||
companion object {
|
||||
fun source(id: Long)
|
||||
= PervEdenLang.values().find { it.id == id }
|
||||
?: throw IllegalArgumentException("Unknown source ID: $id!")
|
||||
}
|
||||
}
|
65
app/src/main/java/exh/metadata/sql/mappers/SearchMetadataTypeMapping.kt
Executable file
65
app/src/main/java/exh/metadata/sql/mappers/SearchMetadataTypeMapping.kt
Executable file
@ -0,0 +1,65 @@
|
||||
package exh.metadata.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_EXTRA
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_EXTRA_VERSION
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_INDEXED_EXTRA
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_MANGA_ID
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.COL_UPLOADER
|
||||
import exh.metadata.sql.tables.SearchMetadataTable.TABLE
|
||||
|
||||
class SearchMetadataTypeMapping : SQLiteTypeMapping<SearchMetadata>(
|
||||
SearchMetadataPutResolver(),
|
||||
SearchMetadataGetResolver(),
|
||||
SearchMetadataDeleteResolver()
|
||||
)
|
||||
|
||||
class SearchMetadataPutResolver : DefaultPutResolver<SearchMetadata>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: SearchMetadata) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: SearchMetadata) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_MANGA_ID = ?")
|
||||
.whereArgs(obj.mangaId)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: SearchMetadata) = ContentValues(5).apply {
|
||||
put(COL_MANGA_ID, obj.mangaId)
|
||||
put(COL_UPLOADER, obj.uploader)
|
||||
put(COL_EXTRA, obj.extra)
|
||||
put(COL_INDEXED_EXTRA, obj.indexedExtra)
|
||||
put(COL_EXTRA_VERSION, obj.extraVersion)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchMetadataGetResolver : DefaultGetResolver<SearchMetadata>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): SearchMetadata = SearchMetadata(
|
||||
mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)),
|
||||
uploader = cursor.getString(cursor.getColumnIndex(COL_UPLOADER)),
|
||||
extra = cursor.getString(cursor.getColumnIndex(COL_EXTRA)),
|
||||
indexedExtra = cursor.getString(cursor.getColumnIndex(COL_INDEXED_EXTRA)),
|
||||
extraVersion = cursor.getInt(cursor.getColumnIndex(COL_EXTRA_VERSION))
|
||||
)
|
||||
}
|
||||
|
||||
class SearchMetadataDeleteResolver : DefaultDeleteResolver<SearchMetadata>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: SearchMetadata) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_MANGA_ID = ?")
|
||||
.whereArgs(obj.mangaId)
|
||||
.build()
|
||||
}
|
65
app/src/main/java/exh/metadata/sql/mappers/SearchTagTypeMapping.kt
Executable file
65
app/src/main/java/exh/metadata/sql/mappers/SearchTagTypeMapping.kt
Executable file
@ -0,0 +1,65 @@
|
||||
package exh.metadata.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_ID
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_MANGA_ID
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_NAME
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_NAMESPACE
|
||||
import exh.metadata.sql.tables.SearchTagTable.COL_TYPE
|
||||
import exh.metadata.sql.tables.SearchTagTable.TABLE
|
||||
|
||||
class SearchTagTypeMapping : SQLiteTypeMapping<SearchTag>(
|
||||
SearchTagPutResolver(),
|
||||
SearchTagGetResolver(),
|
||||
SearchTagDeleteResolver()
|
||||
)
|
||||
|
||||
class SearchTagPutResolver : DefaultPutResolver<SearchTag>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: SearchTag) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: SearchTag) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: SearchTag) = ContentValues(5).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.mangaId)
|
||||
put(COL_NAMESPACE, obj.namespace)
|
||||
put(COL_NAME, obj.name)
|
||||
put(COL_TYPE, obj.type)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchTagGetResolver : DefaultGetResolver<SearchTag>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): SearchTag = SearchTag(
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID)),
|
||||
mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)),
|
||||
namespace = cursor.getString(cursor.getColumnIndex(COL_NAMESPACE)),
|
||||
name = cursor.getString(cursor.getColumnIndex(COL_NAME)),
|
||||
type = cursor.getInt(cursor.getColumnIndex(COL_TYPE))
|
||||
)
|
||||
}
|
||||
|
||||
class SearchTagDeleteResolver : DefaultDeleteResolver<SearchTag>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: SearchTag) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
}
|
62
app/src/main/java/exh/metadata/sql/mappers/SearchTitleTypeMapping.kt
Executable file
62
app/src/main/java/exh/metadata/sql/mappers/SearchTitleTypeMapping.kt
Executable file
@ -0,0 +1,62 @@
|
||||
package exh.metadata.sql.mappers
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.Cursor
|
||||
import com.pushtorefresh.storio.sqlite.SQLiteTypeMapping
|
||||
import com.pushtorefresh.storio.sqlite.operations.delete.DefaultDeleteResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.get.DefaultGetResolver
|
||||
import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.InsertQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_ID
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_MANGA_ID
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_TITLE
|
||||
import exh.metadata.sql.tables.SearchTitleTable.COL_TYPE
|
||||
import exh.metadata.sql.tables.SearchTitleTable.TABLE
|
||||
|
||||
class SearchTitleTypeMapping : SQLiteTypeMapping<SearchTitle>(
|
||||
SearchTitlePutResolver(),
|
||||
SearchTitleGetResolver(),
|
||||
SearchTitleDeleteResolver()
|
||||
)
|
||||
|
||||
class SearchTitlePutResolver : DefaultPutResolver<SearchTitle>() {
|
||||
|
||||
override fun mapToInsertQuery(obj: SearchTitle) = InsertQuery.builder()
|
||||
.table(TABLE)
|
||||
.build()
|
||||
|
||||
override fun mapToUpdateQuery(obj: SearchTitle) = UpdateQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
|
||||
override fun mapToContentValues(obj: SearchTitle) = ContentValues(4).apply {
|
||||
put(COL_ID, obj.id)
|
||||
put(COL_MANGA_ID, obj.mangaId)
|
||||
put(COL_TITLE, obj.title)
|
||||
put(COL_TYPE, obj.type)
|
||||
}
|
||||
}
|
||||
|
||||
class SearchTitleGetResolver : DefaultGetResolver<SearchTitle>() {
|
||||
|
||||
override fun mapFromCursor(cursor: Cursor): SearchTitle = SearchTitle(
|
||||
id = cursor.getLong(cursor.getColumnIndex(COL_ID)),
|
||||
mangaId = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)),
|
||||
title = cursor.getString(cursor.getColumnIndex(COL_TITLE)),
|
||||
type = cursor.getInt(cursor.getColumnIndex(COL_TYPE))
|
||||
)
|
||||
}
|
||||
|
||||
class SearchTitleDeleteResolver : DefaultDeleteResolver<SearchTitle>() {
|
||||
|
||||
override fun mapToDeleteQuery(obj: SearchTitle) = DeleteQuery.builder()
|
||||
.table(TABLE)
|
||||
.where("$COL_ID = ?")
|
||||
.whereArgs(obj.id)
|
||||
.build()
|
||||
}
|
21
app/src/main/java/exh/metadata/sql/models/SearchMetadata.kt
Normal file
21
app/src/main/java/exh/metadata/sql/models/SearchMetadata.kt
Normal file
@ -0,0 +1,21 @@
|
||||
package exh.metadata.sql.models
|
||||
|
||||
data class SearchMetadata(
|
||||
// Manga ID this gallery is linked to
|
||||
val mangaId: Long,
|
||||
|
||||
// Gallery uploader
|
||||
val uploader: String?,
|
||||
|
||||
// Extra data attached to this metadata, in JSON format
|
||||
val extra: String,
|
||||
|
||||
// Indexed extra data attached to this metadata
|
||||
val indexedExtra: String?,
|
||||
|
||||
// The version of this metadata's extra. Used to track changes to the 'extra' field's schema
|
||||
val extraVersion: Int
|
||||
) {
|
||||
// Transient information attached to this piece of metadata, useful for caching
|
||||
var transientCache: Map<String, Any>? = null
|
||||
}
|
18
app/src/main/java/exh/metadata/sql/models/SearchTag.kt
Normal file
18
app/src/main/java/exh/metadata/sql/models/SearchTag.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package exh.metadata.sql.models
|
||||
|
||||
data class SearchTag(
|
||||
// Tag identifier, unique
|
||||
val id: Long?,
|
||||
|
||||
// Metadata this tag is attached to
|
||||
val mangaId: Long,
|
||||
|
||||
// Tag namespace
|
||||
val namespace: String?,
|
||||
|
||||
// Tag name
|
||||
val name: String,
|
||||
|
||||
// Tag type
|
||||
val type: Int
|
||||
)
|
15
app/src/main/java/exh/metadata/sql/models/SearchTitle.kt
Normal file
15
app/src/main/java/exh/metadata/sql/models/SearchTitle.kt
Normal file
@ -0,0 +1,15 @@
|
||||
package exh.metadata.sql.models
|
||||
|
||||
data class SearchTitle(
|
||||
// Title identifier, unique
|
||||
val id: Long?,
|
||||
|
||||
// Metadata this title is attached to
|
||||
val mangaId: Long,
|
||||
|
||||
// Title
|
||||
val title: String,
|
||||
|
||||
// Title type, useful for distinguishing between main/alt titles
|
||||
val type: Int
|
||||
)
|
36
app/src/main/java/exh/metadata/sql/queries/SearchMetadataQueries.kt
Executable file
36
app/src/main/java/exh/metadata/sql/queries/SearchMetadataQueries.kt
Executable file
@ -0,0 +1,36 @@
|
||||
package exh.metadata.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.tables.SearchMetadataTable
|
||||
|
||||
interface SearchMetadataQueries : DbProvider {
|
||||
|
||||
fun getSearchMetadataForManga(mangaId: Long) = db.get()
|
||||
.`object`(SearchMetadata::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchMetadataTable.TABLE)
|
||||
.where("${SearchMetadataTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun getSearchMetadata() = db.get()
|
||||
.listOfObjects(SearchMetadata::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchMetadataTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertSearchMetadata(metadata: SearchMetadata) = db.put().`object`(metadata).prepare()
|
||||
|
||||
fun deleteSearchMetadata(metadata: SearchMetadata) = db.delete().`object`(metadata).prepare()
|
||||
|
||||
fun deleteAllSearchMetadata() = db.delete().byQuery(DeleteQuery.builder()
|
||||
.table(SearchMetadataTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
}
|
45
app/src/main/java/exh/metadata/sql/queries/SearchTagQueries.kt
Executable file
45
app/src/main/java/exh/metadata/sql/queries/SearchTagQueries.kt
Executable file
@ -0,0 +1,45 @@
|
||||
package exh.metadata.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.inTransaction
|
||||
import exh.metadata.sql.models.SearchTag
|
||||
import exh.metadata.sql.tables.SearchTagTable
|
||||
|
||||
interface SearchTagQueries : DbProvider {
|
||||
fun getSearchTagsForManga(mangaId: Long) = db.get()
|
||||
.listOfObjects(SearchTag::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchTagTable.TABLE)
|
||||
.where("${SearchTagTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteSearchTagsForManga(mangaId: Long) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(SearchTagTable.TABLE)
|
||||
.where("${SearchTagTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertSearchTag(searchTag: SearchTag) = db.put().`object`(searchTag).prepare()
|
||||
|
||||
fun insertSearchTags(searchTags: List<SearchTag>) = db.put().objects(searchTags).prepare()
|
||||
|
||||
fun deleteSearchTag(searchTag: SearchTag) = db.delete().`object`(searchTag).prepare()
|
||||
|
||||
fun deleteAllSearchTags() = db.delete().byQuery(DeleteQuery.builder()
|
||||
.table(SearchTagTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun setSearchTagsForManga(mangaId: Long, tags: List<SearchTag>) {
|
||||
db.inTransaction {
|
||||
deleteSearchTagsForManga(mangaId).executeAsBlocking()
|
||||
insertSearchTags(tags).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
47
app/src/main/java/exh/metadata/sql/queries/SearchTitleQueries.kt
Executable file
47
app/src/main/java/exh/metadata/sql/queries/SearchTitleQueries.kt
Executable file
@ -0,0 +1,47 @@
|
||||
package exh.metadata.sql.queries
|
||||
|
||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||
import eu.kanade.tachiyomi.data.database.inTransaction
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import exh.metadata.sql.models.SearchMetadata
|
||||
import exh.metadata.sql.models.SearchTitle
|
||||
import exh.metadata.sql.tables.SearchTitleTable
|
||||
|
||||
interface SearchTitleQueries : DbProvider {
|
||||
fun getSearchTitlesForManga(mangaId: Long) = db.get()
|
||||
.listOfObjects(SearchTitle::class.java)
|
||||
.withQuery(Query.builder()
|
||||
.table(SearchTitleTable.TABLE)
|
||||
.where("${SearchTitleTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun deleteSearchTitlesForManga(mangaId: Long) = db.delete()
|
||||
.byQuery(DeleteQuery.builder()
|
||||
.table(SearchTitleTable.TABLE)
|
||||
.where("${SearchTitleTable.COL_MANGA_ID} = ?")
|
||||
.whereArgs(mangaId)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun insertSearchTitle(searchTitle: SearchTitle) = db.put().`object`(searchTitle).prepare()
|
||||
|
||||
fun insertSearchTitles(searchTitles: List<SearchTitle>) = db.put().objects(searchTitles).prepare()
|
||||
|
||||
fun deleteSearchTitle(searchTitle: SearchTitle) = db.delete().`object`(searchTitle).prepare()
|
||||
|
||||
fun deleteAllSearchTitle() = db.delete().byQuery(DeleteQuery.builder()
|
||||
.table(SearchTitleTable.TABLE)
|
||||
.build())
|
||||
.prepare()
|
||||
|
||||
fun setSearchTitlesForManga(mangaId: Long, titles: List<SearchTitle>) {
|
||||
db.inTransaction {
|
||||
deleteSearchTitlesForManga(mangaId).executeAsBlocking()
|
||||
insertSearchTitles(titles).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
35
app/src/main/java/exh/metadata/sql/tables/SearchMetadataTable.kt
Executable file
35
app/src/main/java/exh/metadata/sql/tables/SearchMetadataTable.kt
Executable file
@ -0,0 +1,35 @@
|
||||
package exh.metadata.sql.tables
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
object SearchMetadataTable {
|
||||
const val TABLE = "search_metadata"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_UPLOADER = "uploader"
|
||||
|
||||
const val COL_EXTRA = "extra"
|
||||
|
||||
const val COL_INDEXED_EXTRA = "indexed_extra"
|
||||
|
||||
const val COL_EXTRA_VERSION = "extra_version"
|
||||
|
||||
// Insane foreign, primary key to avoid touch manga table
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_MANGA_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_UPLOADER TEXT,
|
||||
$COL_EXTRA TEXT NOT NULL,
|
||||
$COL_INDEXED_EXTRA TEXT,
|
||||
$COL_EXTRA_VERSION INT NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createUploaderIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_UPLOADER}_index ON $TABLE($COL_UPLOADER)"
|
||||
|
||||
val createIndexedExtraIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_INDEXED_EXTRA}_index ON $TABLE($COL_INDEXED_EXTRA)"
|
||||
}
|
34
app/src/main/java/exh/metadata/sql/tables/SearchTagTable.kt
Executable file
34
app/src/main/java/exh/metadata/sql/tables/SearchTagTable.kt
Executable file
@ -0,0 +1,34 @@
|
||||
package exh.metadata.sql.tables
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
object SearchTagTable {
|
||||
const val TABLE = "search_tags"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_NAMESPACE = "namespace"
|
||||
|
||||
const val COL_NAME = "name"
|
||||
|
||||
const val COL_TYPE = "type"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_NAMESPACE TEXT,
|
||||
$COL_NAME TEXT NOT NULL,
|
||||
$COL_TYPE INT NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||
|
||||
val createNamespaceNameIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_NAMESPACE}_${COL_NAME}_index ON $TABLE($COL_NAMESPACE, $COL_NAME)"
|
||||
}
|
31
app/src/main/java/exh/metadata/sql/tables/SearchTitleTable.kt
Executable file
31
app/src/main/java/exh/metadata/sql/tables/SearchTitleTable.kt
Executable file
@ -0,0 +1,31 @@
|
||||
package exh.metadata.sql.tables
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||
|
||||
object SearchTitleTable {
|
||||
const val TABLE = "search_titles"
|
||||
|
||||
const val COL_ID = "_id"
|
||||
|
||||
const val COL_MANGA_ID = "manga_id"
|
||||
|
||||
const val COL_TITLE = "title"
|
||||
|
||||
const val COL_TYPE = "type"
|
||||
|
||||
val createTableQuery: String
|
||||
get() = """CREATE TABLE $TABLE(
|
||||
$COL_ID INTEGER NOT NULL PRIMARY KEY,
|
||||
$COL_MANGA_ID INTEGER NOT NULL,
|
||||
$COL_TITLE TEXT NOT NULL,
|
||||
$COL_TYPE INT NOT NULL,
|
||||
FOREIGN KEY($COL_MANGA_ID) REFERENCES ${MangaTable.TABLE} (${MangaTable.COL_ID})
|
||||
ON DELETE CASCADE
|
||||
)"""
|
||||
|
||||
val createMangaIdIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_MANGA_ID}_index ON $TABLE($COL_MANGA_ID)"
|
||||
|
||||
val createTitleIndexQuery: String
|
||||
get() = "CREATE INDEX ${TABLE}_${COL_TITLE}_index ON $TABLE($COL_TITLE)"
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
class MultiWildcard : TextComponent()
|
||||
class MultiWildcard(rawText: String) : TextComponent(rawText)
|
||||
|
@ -1,95 +1,122 @@
|
||||
package exh.search
|
||||
|
||||
import exh.metadata.models.SearchableGalleryMetadata
|
||||
import exh.metadata.models.Tag
|
||||
import io.realm.Case
|
||||
import io.realm.RealmQuery
|
||||
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 <T : SearchableGalleryMetadata> filterResults(rQuery: RealmQuery<T>,
|
||||
query: List<QueryComponent>,
|
||||
titleFields: List<String>):
|
||||
RealmQuery<T> {
|
||||
var queryEmpty = true
|
||||
|
||||
fun matchTagList(namespace: String?,
|
||||
component: Text?,
|
||||
excluded: Boolean) {
|
||||
when {
|
||||
excluded -> rQuery.not()
|
||||
queryEmpty -> queryEmpty = false
|
||||
else -> rQuery.or()
|
||||
}
|
||||
|
||||
rQuery.beginGroup()
|
||||
//Match namespace if specified
|
||||
namespace?.let {
|
||||
rQuery.equalTo("${SearchableGalleryMetadata::tags.name}.${Tag::namespace.name}",
|
||||
it,
|
||||
Case.INSENSITIVE)
|
||||
}
|
||||
//Match tag name if specified
|
||||
component?.let {
|
||||
rQuery.beginGroup()
|
||||
val q = if (!it.exact)
|
||||
fun textToSubQueries(namespace: String?,
|
||||
component: Text?): Pair<String, List<String>>? {
|
||||
val maybeLenientComponent = component?.let {
|
||||
if (!it.exact)
|
||||
it.asLenientTagQueries()
|
||||
else
|
||||
listOf(it.asQuery())
|
||||
q.forEachIndexed { index, s ->
|
||||
if(index > 0)
|
||||
rQuery.or()
|
||||
|
||||
rQuery.like("${SearchableGalleryMetadata::tags.name}.${Tag::name.name}", s, Case.INSENSITIVE)
|
||||
}
|
||||
rQuery.endGroup()
|
||||
}
|
||||
rQuery.endGroup()
|
||||
}
|
||||
val componentTagQuery = maybeLenientComponent?.let {
|
||||
val params = mutableListOf<String>()
|
||||
it.map { q ->
|
||||
params += q
|
||||
"${SearchTagTable.TABLE}.${SearchTagTable.COL_NAME} LIKE ?"
|
||||
}.joinToString(separator = " OR ", prefix = "(", postfix = ")") to params
|
||||
}
|
||||
return if(namespace != null) {
|
||||
var query = """
|
||||
(SELECT ${SearchTagTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTagTable.TABLE}
|
||||
WHERE ${SearchTagTable.COL_NAMESPACE} IS NOT NULL
|
||||
AND ${SearchTagTable.COL_NAMESPACE} LIKE ?
|
||||
""".trimIndent()
|
||||
val params = mutableListOf(escapeLike(namespace))
|
||||
if(componentTagQuery != null) {
|
||||
query += "\n AND ${componentTagQuery.first}"
|
||||
params += componentTagQuery.second
|
||||
}
|
||||
|
||||
for(component in query) {
|
||||
if(component is Text) {
|
||||
if(component.excluded)
|
||||
rQuery.not()
|
||||
"$query)" to params
|
||||
} else if(component != null) {
|
||||
// Match title + tags
|
||||
val tagQuery = """
|
||||
SELECT ${SearchTagTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTagTable.TABLE}
|
||||
WHERE ${componentTagQuery!!.first}
|
||||
""".trimIndent() to componentTagQuery.second
|
||||
|
||||
rQuery.beginGroup()
|
||||
val titleQuery = """
|
||||
SELECT ${SearchTitleTable.COL_MANGA_ID} AS $COL_MANGA_ID FROM ${SearchTitleTable.TABLE}
|
||||
WHERE ${SearchTitleTable.COL_TITLE} LIKE ?
|
||||
""".trimIndent() to listOf(component.asLenientTitleQuery())
|
||||
|
||||
//Match title
|
||||
titleFields.forEachIndexed { index, s ->
|
||||
queryEmpty = false
|
||||
if(index > 0)
|
||||
rQuery.or()
|
||||
"(${tagQuery.first} UNION ${titleQuery.first})".trimIndent() to
|
||||
(tagQuery.second + titleQuery.second)
|
||||
} else null
|
||||
}
|
||||
|
||||
rQuery.like(s, component.asLenientTitleQuery(), Case.INSENSITIVE)
|
||||
}
|
||||
fun queryToSql(q: List<QueryComponent>): Pair<String, List<String>> {
|
||||
val wheres = mutableListOf<String>()
|
||||
val whereParams = mutableListOf<String>()
|
||||
|
||||
//Match tags
|
||||
matchTagList(null, component, false) //We already deal with exclusions here
|
||||
rQuery.endGroup()
|
||||
val include = mutableListOf<Pair<String, List<String>>>()
|
||||
val exclude = mutableListOf<Pair<String, List<String>>>()
|
||||
|
||||
for(component in q) {
|
||||
val query = if(component is Text) {
|
||||
textToSubQueries(null, component)
|
||||
} else if(component is Namespace) {
|
||||
if(component.namespace == "uploader") {
|
||||
queryEmpty = false
|
||||
//Match uploader
|
||||
rQuery.equalTo(SearchableGalleryMetadata::uploader.name,
|
||||
component.tag!!.rawTextOnly(),
|
||||
Case.INSENSITIVE)
|
||||
wheres += "meta.${SearchMetadataTable.COL_UPLOADER} LIKE ?"
|
||||
whereParams += component.tag!!.rawTextEscapedForLike()
|
||||
null
|
||||
} else {
|
||||
if(component.tag!!.components.size > 0) {
|
||||
//Match namespace + tags
|
||||
matchTagList(component.namespace, component.tag!!, component.tag!!.excluded)
|
||||
textToSubQueries(component.namespace, component.tag)
|
||||
} else {
|
||||
//Perform namespace search
|
||||
matchTagList(component.namespace, null, component.excluded)
|
||||
textToSubQueries(component.namespace, null)
|
||||
}
|
||||
}
|
||||
} else error("Unknown query component!")
|
||||
|
||||
if(query != null) {
|
||||
(if(component.excluded) exclude else include) += query
|
||||
}
|
||||
}
|
||||
return rQuery
|
||||
|
||||
val completeParams = mutableListOf<String>()
|
||||
var baseQuery = """
|
||||
SELECT ${SearchMetadataTable.COL_MANGA_ID}
|
||||
FROM ${SearchMetadataTable.TABLE} meta
|
||||
""".trimIndent()
|
||||
|
||||
include.forEachIndexed { index, pair ->
|
||||
baseQuery += "\n" + ("""
|
||||
INNER JOIN ${pair.first} i$index
|
||||
ON i$index.$COL_MANGA_ID = meta.${SearchMetadataTable.COL_MANGA_ID}
|
||||
""".trimIndent())
|
||||
completeParams += pair.second
|
||||
}
|
||||
|
||||
|
||||
exclude.forEach {
|
||||
wheres += """
|
||||
(meta.${SearchMetadataTable.COL_MANGA_ID} NOT IN ${it.first})
|
||||
""".trimIndent()
|
||||
whereParams += it.second
|
||||
}
|
||||
if(wheres.isNotEmpty()) {
|
||||
completeParams += whereParams
|
||||
baseQuery += "\nWHERE\n"
|
||||
baseQuery += wheres.joinToString("\nAND\n")
|
||||
}
|
||||
baseQuery += "\nORDER BY ${SearchMetadataTable.COL_MANGA_ID}"
|
||||
|
||||
return baseQuery to completeParams
|
||||
}
|
||||
|
||||
fun parseQuery(query: String) = queryCache.getOrPut(query, {
|
||||
fun parseQuery(query: String) = queryCache.getOrPut(query) {
|
||||
val res = mutableListOf<QueryComponent>()
|
||||
|
||||
var inQuotes = false
|
||||
@ -130,10 +157,10 @@ class SearchEngine {
|
||||
inQuotes = !inQuotes
|
||||
} else if(char == '?' || char == '_') {
|
||||
flushText()
|
||||
queuedText.add(SingleWildcard())
|
||||
queuedText.add(SingleWildcard(char.toString()))
|
||||
} else if(char == '*' || char == '%') {
|
||||
flushText()
|
||||
queuedText.add(MultiWildcard())
|
||||
queuedText.add(MultiWildcard(char.toString()))
|
||||
} else if(char == '-') {
|
||||
nextIsExcluded = true
|
||||
} else if(char == '$') {
|
||||
@ -163,5 +190,16 @@ class SearchEngine {
|
||||
flushAll()
|
||||
|
||||
res
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val COL_MANGA_ID = "cmid"
|
||||
|
||||
fun escapeLike(string: String): String {
|
||||
return string.replace("\\", "\\\\")
|
||||
.replace("_", "\\_")
|
||||
.replace("%", "\\%")
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
class SingleWildcard : TextComponent()
|
||||
class SingleWildcard(rawText: String) : TextComponent(rawText)
|
||||
|
@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
class StringTextComponent(val value: String) : TextComponent()
|
||||
class StringTextComponent(val value: String) : TextComponent(value)
|
||||
|
@ -1,6 +1,7 @@
|
||||
package exh.search
|
||||
|
||||
import exh.plusAssign
|
||||
import exh.search.SearchEngine.Companion.escapeLike
|
||||
|
||||
class Text: QueryComponent() {
|
||||
val components = mutableListOf<TextComponent>()
|
||||
@ -19,7 +20,7 @@ class Text: QueryComponent() {
|
||||
|
||||
fun asLenientTitleQuery(): String {
|
||||
if(lenientTitleQuery == null) {
|
||||
lenientTitleQuery = StringBuilder("*").append(rBaseBuilder()).append("*").toString()
|
||||
lenientTitleQuery = StringBuilder("%").append(rBaseBuilder()).append("%").toString()
|
||||
}
|
||||
return lenientTitleQuery!!
|
||||
}
|
||||
@ -28,7 +29,7 @@ class Text: QueryComponent() {
|
||||
if(lenientTagQueries == null) {
|
||||
lenientTagQueries = listOf(
|
||||
//Match beginning of tag
|
||||
rBaseBuilder().append("*").toString(),
|
||||
rBaseBuilder().append("%").toString(),
|
||||
//Tag word matcher (that matches multiple words)
|
||||
//Can't make it match a single word in Realm :(
|
||||
StringBuilder(" ").append(rBaseBuilder()).append(" ").toString(),
|
||||
@ -43,9 +44,9 @@ class Text: QueryComponent() {
|
||||
val builder = StringBuilder()
|
||||
for(component in components) {
|
||||
when(component) {
|
||||
is StringTextComponent -> builder += component.value
|
||||
is SingleWildcard -> builder += "?"
|
||||
is MultiWildcard -> builder += "*"
|
||||
is StringTextComponent -> builder += escapeLike(component.value)
|
||||
is SingleWildcard -> builder += "_"
|
||||
is MultiWildcard -> builder += "%"
|
||||
}
|
||||
}
|
||||
return builder
|
||||
@ -55,10 +56,9 @@ class Text: QueryComponent() {
|
||||
rawText!!
|
||||
else {
|
||||
rawText = components
|
||||
.filter { it is StringTextComponent }
|
||||
.joinToString(separator = "", transform = {
|
||||
(it as StringTextComponent).value
|
||||
})
|
||||
.joinToString(separator = "", transform = { it.rawText })
|
||||
rawText!!
|
||||
}
|
||||
|
||||
fun rawTextEscapedForLike() = escapeLike(rawTextOnly())
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
package exh.search
|
||||
|
||||
open class TextComponent
|
||||
open class TextComponent(val rawText: String)
|
||||
|
238
app/src/main/java/exh/source/DelegatedHttpSource.kt
Normal file
238
app/src/main/java/exh/source/DelegatedHttpSource.kt
Normal file
@ -0,0 +1,238 @@
|
||||
package exh.source
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import java.lang.RuntimeException
|
||||
|
||||
abstract class DelegatedHttpSource(val delegate: HttpSource): HttpSource() {
|
||||
/**
|
||||
* Returns the request for the popular manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun popularMangaRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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 never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Returns the request for latest manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun latestUpdatesRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of pages.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun pageListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
*/
|
||||
override val baseUrl = delegate.baseUrl
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
override val supportsLatest = delegate.supportsLatest
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
final override val name = delegate.name
|
||||
|
||||
// ===> OPTIONAL FIELDS
|
||||
|
||||
/**
|
||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string: sourcename/language/versionId
|
||||
* Note the generated id sets the sign bit to 0.
|
||||
*/
|
||||
override val id = delegate.id
|
||||
/**
|
||||
* Default network client for doing requests.
|
||||
*/
|
||||
override val client = delegate.client
|
||||
|
||||
/**
|
||||
* Visible name of the source.
|
||||
*/
|
||||
override fun toString() = delegate.toString()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchPopularManga(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchSearchManga(page, query, filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchLatestUpdates(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchMangaDetails(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request for the details of a manga. Override only if it's needed to change the
|
||||
* url, send different headers or request method like POST.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.mangaDetailsRequest(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||
* override this method. If a manga is licensed an empty chapter list observable is returned
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
*/
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchChapterList(manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchPageList(chapter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an observable with the page containing the source url of the image. If there's any
|
||||
* error, it will return null instead of throwing an exception.
|
||||
*
|
||||
* @param page the page whose source image has to be fetched.
|
||||
*/
|
||||
override fun fetchImageUrl(page: Page): Observable<String> {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.fetchImageUrl(page)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||
*
|
||||
* @param chapter the chapter to be added.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
override fun prepareNewChapter(chapter: SChapter, manga: SManga) {
|
||||
ensureDelegateCompatible()
|
||||
return delegate.prepareNewChapter(chapter, manga)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
override fun getFilterList() = delegate.getFilterList()
|
||||
|
||||
private fun ensureDelegateCompatible() {
|
||||
if(versionId != delegate.versionId
|
||||
|| lang != delegate.lang) {
|
||||
throw IncompatibleDelegateException("Delegate source is not compatible (versionId: $versionId <=> ${delegate.versionId}, lang: $lang <=> ${delegate.lang})!")
|
||||
}
|
||||
}
|
||||
|
||||
class IncompatibleDelegateException(message: String) : RuntimeException(message)
|
||||
}
|
220
app/src/main/java/exh/source/EnhancedHttpSource.kt
Normal file
220
app/src/main/java/exh/source/EnhancedHttpSource.kt
Normal file
@ -0,0 +1,220 @@
|
||||
package exh.source
|
||||
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.model.*
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import okhttp3.Response
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class EnhancedHttpSource(val originalSource: HttpSource,
|
||||
val enchancedSource: HttpSource): HttpSource() {
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
/**
|
||||
* Returns the request for the popular manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun popularMangaRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun popularMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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 never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun searchMangaParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Returns the request for latest manga given the page.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun latestUpdatesRequest(page: Int)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a [MangasPage] object.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun latestUpdatesParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of chapters.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun chapterListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Parses the response from the site and returns a list of pages.
|
||||
*
|
||||
* @param response the response from the site.
|
||||
*/
|
||||
override fun pageListParse(response: Response)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= throw UnsupportedOperationException("Should never be called!")
|
||||
|
||||
/**
|
||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||
*/
|
||||
override val baseUrl = source().baseUrl
|
||||
|
||||
/**
|
||||
* Whether the source has support for latest updates.
|
||||
*/
|
||||
override val supportsLatest = source().supportsLatest
|
||||
/**
|
||||
* Name of the source.
|
||||
*/
|
||||
override val name = source().name
|
||||
|
||||
/**
|
||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||
*/
|
||||
override val lang = source().lang
|
||||
|
||||
// ===> OPTIONAL FIELDS
|
||||
|
||||
/**
|
||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
||||
* of the MD5 of the string: sourcename/language/versionId
|
||||
* Note the generated id sets the sign bit to 0.
|
||||
*/
|
||||
override val id = source().id
|
||||
/**
|
||||
* Default network client for doing requests.
|
||||
*/
|
||||
override val client = source().client
|
||||
|
||||
/**
|
||||
* Visible name of the source.
|
||||
*/
|
||||
override fun toString() = source().toString()
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
override fun fetchPopularManga(page: Int) = source().fetchPopularManga(page)
|
||||
|
||||
/**
|
||||
* 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)
|
||||
= source().fetchSearchManga(page, query, filters)
|
||||
|
||||
/**
|
||||
* Returns an observable containing a page with a list of latest manga updates.
|
||||
*
|
||||
* @param page the page number to retrieve.
|
||||
*/
|
||||
override fun fetchLatestUpdates(page: Int) = source().fetchLatestUpdates(page)
|
||||
|
||||
/**
|
||||
* 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) = source().fetchMangaDetails(manga)
|
||||
|
||||
/**
|
||||
* Returns the request for the details of a manga. Override only if it's needed to change the
|
||||
* url, send different headers or request method like POST.
|
||||
*
|
||||
* @param manga the manga to be updated.
|
||||
*/
|
||||
override fun mangaDetailsRequest(manga: SManga) = source().mangaDetailsRequest(manga)
|
||||
|
||||
/**
|
||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||
* override this method. If a manga is licensed an empty chapter list observable is returned
|
||||
*
|
||||
* @param manga the manga to look for chapters.
|
||||
*/
|
||||
override fun fetchChapterList(manga: SManga) = source().fetchChapterList(manga)
|
||||
|
||||
/**
|
||||
* Returns an observable with the page list for a chapter.
|
||||
*
|
||||
* @param chapter the chapter whose page list has to be fetched.
|
||||
*/
|
||||
override fun fetchPageList(chapter: SChapter) = source().fetchPageList(chapter)
|
||||
|
||||
/**
|
||||
* Returns an observable with the page containing the source url of the image. If there's any
|
||||
* error, it will return null instead of throwing an exception.
|
||||
*
|
||||
* @param page the page whose source image has to be fetched.
|
||||
*/
|
||||
override fun fetchImageUrl(page: Page) = source().fetchImageUrl(page)
|
||||
|
||||
/**
|
||||
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||
*
|
||||
* @param chapter the chapter to be added.
|
||||
* @param manga the manga of the chapter.
|
||||
*/
|
||||
override fun prepareNewChapter(chapter: SChapter, manga: SManga)
|
||||
= source().prepareNewChapter(chapter, manga)
|
||||
|
||||
/**
|
||||
* Returns the list of filters for the source.
|
||||
*/
|
||||
override fun getFilterList() = source().getFilterList()
|
||||
|
||||
private fun source(): HttpSource {
|
||||
return if(prefs.eh_delegateSources().getOrDefault()) {
|
||||
enchancedSource
|
||||
} else {
|
||||
originalSource
|
||||
}
|
||||
}
|
||||
}
|
@ -92,10 +92,10 @@ object Entry {
|
||||
override val key = "tl"
|
||||
}
|
||||
|
||||
//Locked to list mode as that's what the parser and toplists use
|
||||
//Locked to extended mode as that's what the parser and toplists use
|
||||
class DisplayMode: ConfigItem {
|
||||
override val key = "dm"
|
||||
override val value = "0"
|
||||
override val value = "2"
|
||||
}
|
||||
|
||||
enum class SearchResultsCount(override val value: String): ConfigItem {
|
||||
|
36
app/src/main/java/exh/ui/captcha/AutoSolvingWebViewClient.kt
Normal file
36
app/src/main/java/exh/ui/captcha/AutoSolvingWebViewClient.kt
Normal file
@ -0,0 +1,36 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.os.Build
|
||||
import android.support.annotation.RequiresApi
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.ui.captcha.SolveCaptchaActivity.Companion.CROSS_WINDOW_SCRIPT_INNER
|
||||
import org.jsoup.nodes.DataNode
|
||||
import org.jsoup.nodes.Element
|
||||
import java.nio.charset.Charset
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
class AutoSolvingWebViewClient(activity: SolveCaptchaActivity,
|
||||
source: CaptchaCompletionVerifier,
|
||||
injectScript: String?)
|
||||
: BasicWebViewClient(activity, source, injectScript) {
|
||||
|
||||
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
// Inject our custom script into the recaptcha iframes
|
||||
val lastPathSegment = request.url.pathSegments.lastOrNull()
|
||||
if(lastPathSegment == "anchor" || lastPathSegment == "bframe") {
|
||||
val oReq = request.toOkHttpRequest()
|
||||
val response = activity.httpClient.newCall(oReq).execute()
|
||||
val doc = response.asJsoup()
|
||||
doc.body().appendChild(Element("script").appendChild(DataNode(CROSS_WINDOW_SCRIPT_INNER)))
|
||||
return WebResourceResponse(
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
doc.toString().byteInputStream(Charset.forName("UTF-8")).buffered()
|
||||
)
|
||||
}
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
18
app/src/main/java/exh/ui/captcha/BasicWebViewClient.kt
Normal file
18
app/src/main/java/exh/ui/captcha/BasicWebViewClient.kt
Normal file
@ -0,0 +1,18 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
|
||||
open class BasicWebViewClient(protected val activity: SolveCaptchaActivity,
|
||||
protected val source: CaptchaCompletionVerifier,
|
||||
private val injectScript: String?) : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
|
||||
if(source.verifyNoCaptcha(url)) {
|
||||
activity.finish()
|
||||
} else {
|
||||
if(injectScript != null) view.loadUrl("javascript:(function() {$injectScript})();")
|
||||
}
|
||||
}
|
||||
}
|
@ -4,25 +4,48 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.support.annotation.RequiresApi
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.CookieSyncManager
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import eu.kanade.tachiyomi.R
|
||||
import android.webkit.*
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import kotlinx.android.synthetic.main.eh_activity_captcha.*
|
||||
import okhttp3.*
|
||||
import rx.Single
|
||||
import rx.schedulers.Schedulers
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import android.view.MotionEvent
|
||||
import android.os.SystemClock
|
||||
import com.afollestad.materialdialogs.MaterialDialog
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import exh.util.melt
|
||||
import rx.Observable
|
||||
|
||||
class SolveCaptchaActivity : AppCompatActivity() {
|
||||
private val sourceManager: SourceManager by injectLazy()
|
||||
private val preferencesHelper: PreferencesHelper by injectLazy()
|
||||
|
||||
val httpClient = OkHttpClient()
|
||||
private val jsonParser = JsonParser()
|
||||
|
||||
private var currentLoopId: String? = null
|
||||
private var validateCurrentLoopId: String? = null
|
||||
private var strictValidationStartTime: Long? = null
|
||||
|
||||
lateinit var credentialsObservable: Observable<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContentView(R.layout.eh_activity_captcha)
|
||||
setContentView(eu.kanade.tachiyomi.R.layout.eh_activity_captcha)
|
||||
|
||||
val sourceId = intent.getLongExtra(SOURCE_ID_EXTRA, -1)
|
||||
val source = if(sourceId != -1L)
|
||||
@ -59,18 +82,56 @@ class SolveCaptchaActivity : AppCompatActivity() {
|
||||
webview.settings.javaScriptEnabled = true
|
||||
webview.settings.domStorageEnabled = true
|
||||
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
var loadedInners = 0
|
||||
|
||||
if(source.verify(url)) {
|
||||
finish()
|
||||
} else {
|
||||
view.loadUrl("javascript:(function() {$script})();")
|
||||
webview.webChromeClient = object : WebChromeClient() {
|
||||
override fun onJsAlert(view: WebView?, url: String?, message: String, result: JsResult): Boolean {
|
||||
if(message.startsWith("exh-")) {
|
||||
loadedInners++
|
||||
// Wait for both inner scripts to be loaded
|
||||
if(loadedInners >= 2) {
|
||||
// Attempt to autosolve captcha
|
||||
if(preferencesHelper.eh_autoSolveCaptchas().getOrDefault()
|
||||
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
webview.post {
|
||||
// 10 seconds to auto-solve captcha
|
||||
strictValidationStartTime = System.currentTimeMillis() + 1000 * 10
|
||||
beginSolveLoop()
|
||||
beginValidateCaptchaLoop()
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE) {
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_SHOW, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.confirm()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
webview.webViewClient = if (preferencesHelper.eh_autoSolveCaptchas().getOrDefault()
|
||||
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
// Fetch auto-solve credentials early for speed
|
||||
credentialsObservable = httpClient.newCall(Request.Builder()
|
||||
// Rob demo credentials
|
||||
.url("https://speech-to-text-demo.ng.bluemix.net/api/v1/credentials")
|
||||
.build())
|
||||
.asObservableSuccess()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.map {
|
||||
val json = jsonParser.parse(it.body()!!.string())
|
||||
it.close()
|
||||
json["token"].string
|
||||
}.melt()
|
||||
|
||||
webview.addJavascriptInterface(this@SolveCaptchaActivity, "exh")
|
||||
AutoSolvingWebViewClient(this, source, script)
|
||||
} else {
|
||||
BasicWebViewClient(this, source, script)
|
||||
}
|
||||
|
||||
webview.loadUrl(url)
|
||||
}
|
||||
|
||||
@ -91,22 +152,458 @@ class SolveCaptchaActivity : AppCompatActivity() {
|
||||
return true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.KITKAT)
|
||||
fun captchaSolveFail() {
|
||||
currentLoopId = null
|
||||
validateCurrentLoopId = null
|
||||
Timber.e(IllegalStateException("Captcha solve failure!"))
|
||||
runOnUiThread {
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null)
|
||||
MaterialDialog.Builder(this)
|
||||
.title("Captcha solve failure")
|
||||
.content("Failed to auto-solve the captcha!")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.positiveText("Ok")
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
@JavascriptInterface
|
||||
fun callback(result: String?, loopId: String, stage: Int) {
|
||||
if(loopId != currentLoopId) return
|
||||
|
||||
when(stage) {
|
||||
STAGE_CHECKBOX -> {
|
||||
if(result!!.toBoolean()) {
|
||||
webview.postDelayed({
|
||||
getAudioButtonLocation(loopId)
|
||||
}, 250)
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
doStageCheckbox(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
STAGE_GET_AUDIO_BTN_LOCATION -> {
|
||||
if(result != null) {
|
||||
val splitResult = result.split(" ").map { it.toFloat() }
|
||||
val origX = splitResult[0]
|
||||
val origY = splitResult[1]
|
||||
val iw = splitResult[2]
|
||||
val ih = splitResult[3]
|
||||
val x = webview.x + origX / iw * webview.width
|
||||
val y = webview.y + origY / ih * webview.height
|
||||
Timber.d("Found audio button coords: %f %f", x, y)
|
||||
simulateClick(x + 50, y + 50)
|
||||
webview.post {
|
||||
doStageDownloadAudio(loopId)
|
||||
}
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
getAudioButtonLocation(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
STAGE_DOWNLOAD_AUDIO -> {
|
||||
if(result != null) {
|
||||
Timber.d("Got audio URL: $result")
|
||||
performRecognize(result)
|
||||
.observeOn(Schedulers.io())
|
||||
.subscribe ({
|
||||
Timber.d("Got audio transcript: $it")
|
||||
webview.post {
|
||||
typeResult(loopId, it!!
|
||||
.replace(TRANSCRIPT_CLEANER_REGEX, "")
|
||||
.replace(SPACE_DEDUPE_REGEX, " ")
|
||||
.trim())
|
||||
}
|
||||
}, {
|
||||
captchaSolveFail()
|
||||
})
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
doStageDownloadAudio(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
STAGE_TYPE_RESULT -> {
|
||||
if(result!!.toBoolean()) {
|
||||
// Fail if captcha still not solved after 1.5s
|
||||
strictValidationStartTime = System.currentTimeMillis() + 1500
|
||||
} else {
|
||||
captchaSolveFail()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun performRecognize(url: String): Single<String> {
|
||||
return credentialsObservable.flatMap { token ->
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(url)
|
||||
.build()).asObservableSuccess().map {
|
||||
token to it
|
||||
}
|
||||
}.flatMap { (token, response) ->
|
||||
val audioFile = response.body()!!.bytes()
|
||||
|
||||
httpClient.newCall(Request.Builder()
|
||||
.url(HttpUrl.parse("https://stream.watsonplatform.net/speech-to-text/api/v1/recognize")!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("watson-token", token)
|
||||
.build())
|
||||
.post(MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("jsonDescription", RECOGNIZE_JSON)
|
||||
.addFormDataPart("audio.mp3",
|
||||
"audio.mp3",
|
||||
RequestBody.create(MediaType.parse("audio/mp3"), audioFile))
|
||||
.build())
|
||||
.build()).asObservableSuccess()
|
||||
}.map { response ->
|
||||
jsonParser.parse(response.body()!!.string())["results"][0]["alternatives"][0]["transcript"].string.trim()
|
||||
}.toSingle()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun doStageCheckbox(loopId: String) {
|
||||
if(loopId != currentLoopId) return
|
||||
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]');
|
||||
|
||||
if(exh_cframe != null) {
|
||||
cwmExec(exh_cframe, `
|
||||
let exh_cb = document.getElementsByClassName('recaptcha-checkbox-checkmark')[0];
|
||||
if(exh_cb != null) {
|
||||
exh_cb.click();
|
||||
return "true";
|
||||
} else {
|
||||
return "false";
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_CHECKBOX);
|
||||
});
|
||||
} else {
|
||||
exh.callback("false", '$loopId', $STAGE_CHECKBOX);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun getAudioButtonLocation(loopId: String) {
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
let bfb = exh_bframe.getBoundingClientRect();
|
||||
let iw = window.innerWidth;
|
||||
let ih = window.innerHeight;
|
||||
if(bfb.left < 0 || bfb.top < 0) {
|
||||
exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
} else {
|
||||
cwmExec(exh_bframe, ` let exh_ab = document.getElementById("recaptcha-audio-button");
|
||||
if(exh_ab != null) {
|
||||
let bounds = exh_ab.getBoundingClientRect();
|
||||
return (${'$'}{bfb.left} + bounds.left) + " " + (${'$'}{bfb.top} + bounds.top) + " " + ${'$'}{iw} + " " + ${'$'}{ih};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
exh.callback(null, '$loopId', $STAGE_GET_AUDIO_BTN_LOCATION);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun doStageDownloadAudio(loopId: String) {
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
cwmExec(exh_bframe, `
|
||||
let exh_as = document.getElementById("audio-source");
|
||||
if(exh_as != null) {
|
||||
return exh_as.src;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_DOWNLOAD_AUDIO);
|
||||
});
|
||||
} else {
|
||||
exh.callback(null, '$loopId', $STAGE_DOWNLOAD_AUDIO);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun typeResult(loopId: String, result: String) {
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_bframe = document.querySelector("iframe[title='recaptcha challenge'][name|=c]");
|
||||
|
||||
if(exh_bframe != null) {
|
||||
cwmExec(exh_bframe, `
|
||||
let exh_as = document.getElementById("audio-response");
|
||||
let exh_vb = document.getElementById("recaptcha-verify-button");
|
||||
if(exh_as != null && exh_vb != null) {
|
||||
exh_as.value = "$result";
|
||||
exh_vb.click();
|
||||
return "true";
|
||||
} else {
|
||||
return "false";
|
||||
}
|
||||
`, function(result) {
|
||||
exh.callback(result, '$loopId', $STAGE_TYPE_RESULT);
|
||||
});
|
||||
} else {
|
||||
exh.callback("false", '$loopId', $STAGE_TYPE_RESULT);
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun beginSolveLoop() {
|
||||
val loopId = UUID.randomUUID().toString()
|
||||
currentLoopId = loopId
|
||||
doStageCheckbox(loopId)
|
||||
}
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
@JavascriptInterface
|
||||
fun validateCaptchaCallback(result: Boolean, loopId: String) {
|
||||
if(loopId != validateCurrentLoopId) return
|
||||
|
||||
if(result) {
|
||||
Timber.d("Captcha solved!")
|
||||
webview.post {
|
||||
webview.evaluateJavascript(SOLVE_UI_SCRIPT_HIDE, null)
|
||||
}
|
||||
val asbtn = intent.getStringExtra(ASBTN_EXTRA)
|
||||
if(asbtn != null) {
|
||||
webview.post {
|
||||
webview.evaluateJavascript("(function() {document.querySelector('$asbtn').click();})();", null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val savedStrictValidationStartTime = strictValidationStartTime
|
||||
if(savedStrictValidationStartTime != null
|
||||
&& System.currentTimeMillis() > savedStrictValidationStartTime) {
|
||||
captchaSolveFail()
|
||||
} else {
|
||||
webview.postDelayed({
|
||||
runValidateCaptcha(loopId)
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun runValidateCaptcha(loopId: String) {
|
||||
if(loopId != validateCurrentLoopId) return
|
||||
|
||||
webview.evaluateJavascript("""
|
||||
(function() {
|
||||
$CROSS_WINDOW_SCRIPT_OUTER
|
||||
|
||||
let exh_cframe = document.querySelector('iframe[role=presentation][name|=a]');
|
||||
|
||||
if(exh_cframe != null) {
|
||||
cwmExec(exh_cframe, `
|
||||
let exh_cb = document.querySelector(".recaptcha-checkbox[aria-checked=true]");
|
||||
if(exh_cb != null) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
`, function(result) {
|
||||
exh.validateCaptchaCallback(result, '$loopId');
|
||||
});
|
||||
} else {
|
||||
exh.validateCaptchaCallback(false, '$loopId');
|
||||
}
|
||||
})();
|
||||
""".trimIndent().replace("\n", ""), null)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun beginValidateCaptchaLoop() {
|
||||
val loopId = UUID.randomUUID().toString()
|
||||
validateCurrentLoopId = loopId
|
||||
runValidateCaptcha(loopId)
|
||||
}
|
||||
|
||||
private fun simulateClick(x: Float, y: Float) {
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
val eventTime = SystemClock.uptimeMillis()
|
||||
val properties = arrayOfNulls<MotionEvent.PointerProperties>(1)
|
||||
val pp1 = MotionEvent.PointerProperties().apply {
|
||||
id = 0
|
||||
toolType = MotionEvent.TOOL_TYPE_FINGER
|
||||
}
|
||||
properties[0] = pp1
|
||||
val pointerCoords = arrayOfNulls<MotionEvent.PointerCoords>(1)
|
||||
val pc1 = MotionEvent.PointerCoords().apply {
|
||||
this.x = x
|
||||
this.y = y
|
||||
pressure = 1f
|
||||
size = 1f
|
||||
}
|
||||
pointerCoords[0] = pc1
|
||||
var motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_DOWN, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
dispatchTouchEvent(motionEvent)
|
||||
motionEvent.recycle()
|
||||
motionEvent = MotionEvent.obtain(downTime, eventTime, MotionEvent.ACTION_UP, 1, properties, pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0)
|
||||
dispatchTouchEvent(motionEvent)
|
||||
motionEvent.recycle()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SOURCE_ID_EXTRA = "source_id_extra"
|
||||
const val COOKIES_EXTRA = "cookies_extra"
|
||||
const val SCRIPT_EXTRA = "script_extra"
|
||||
const val URL_EXTRA = "url_extra"
|
||||
const val ASBTN_EXTRA = "asbtn_extra"
|
||||
|
||||
const val STAGE_CHECKBOX = 0
|
||||
const val STAGE_GET_AUDIO_BTN_LOCATION = 1
|
||||
const val STAGE_DOWNLOAD_AUDIO = 2
|
||||
const val STAGE_TYPE_RESULT = 3
|
||||
|
||||
val CROSS_WINDOW_SCRIPT_OUTER = """
|
||||
function cwmExec(element, code, cb) {
|
||||
console.log(">>> [CWM-Outer] Running: " + code);
|
||||
let runId = Math.random();
|
||||
if(cb != null) {
|
||||
let listener;
|
||||
listener = function(event) {
|
||||
if(typeof event.data === "string" && event.data.startsWith("exh-")) {
|
||||
let response = JSON.parse(event.data.substring(4));
|
||||
if(response.id === runId) {
|
||||
cb(response.result);
|
||||
window.removeEventListener('message', listener);
|
||||
console.log(">>> [CWM-Outer] Finished: " + response.id + " ==> " + response.result);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', listener, false);
|
||||
}
|
||||
let runRequest = { id: runId, code: code };
|
||||
element.contentWindow.postMessage("exh-" + JSON.stringify(runRequest), "*");
|
||||
}
|
||||
""".trimIndent().replace("\n", "")
|
||||
|
||||
val CROSS_WINDOW_SCRIPT_INNER = """
|
||||
window.addEventListener('message', function(event) {
|
||||
if(typeof event.data === "string" && event.data.startsWith("exh-")) {
|
||||
let request = JSON.parse(event.data.substring(4));
|
||||
console.log(">>> [CWM-Inner] Incoming: " + request.id);
|
||||
let result = eval("(function() {" + request.code + "})();");
|
||||
let response = { id: request.id, result: result };
|
||||
console.log(">>> [CWM-Inner] Outgoing: " + response.id + " ==> " + response.result);
|
||||
event.source.postMessage("exh-" + JSON.stringify(response), event.origin);
|
||||
}
|
||||
}, false);
|
||||
console.log(">>> [CWM-Inner] Loaded!");
|
||||
alert("exh-");
|
||||
""".trimIndent()
|
||||
|
||||
val SOLVE_UI_SCRIPT_SHOW = """
|
||||
(function() {
|
||||
let exh_overlay = document.createElement("div");
|
||||
exh_overlay.id = "exh_overlay";
|
||||
exh_overlay.style.zIndex = 2000000001;
|
||||
exh_overlay.style.backgroundColor = "rgba(0, 0, 0, 0.8)";
|
||||
exh_overlay.style.position = "fixed";
|
||||
exh_overlay.style.top = 0;
|
||||
exh_overlay.style.left = 0;
|
||||
exh_overlay.style.width = "100%";
|
||||
exh_overlay.style.height = "100%";
|
||||
exh_overlay.style.pointerEvents = "none";
|
||||
document.body.appendChild(exh_overlay);
|
||||
let exh_otext = document.createElement("div");
|
||||
exh_otext.id = "exh_otext";
|
||||
exh_otext.style.zIndex = 2000000002;
|
||||
exh_otext.style.position = "fixed";
|
||||
exh_otext.style.top = "50%";
|
||||
exh_otext.style.left = 0;
|
||||
exh_otext.style.transform = "translateY(-50%)";
|
||||
exh_otext.style.color = "white";
|
||||
exh_otext.style.fontSize = "25pt";
|
||||
exh_otext.style.pointerEvents = "none";
|
||||
exh_otext.style.width = "100%";
|
||||
exh_otext.style.textAlign = "center";
|
||||
exh_otext.textContent = "Solving captcha..."
|
||||
document.body.appendChild(exh_otext);
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
val SOLVE_UI_SCRIPT_HIDE = """
|
||||
(function() {
|
||||
let exh_overlay = document.getElementById("exh_overlay");
|
||||
let exh_otext = document.getElementById("exh_otext");
|
||||
if(exh_overlay != null) exh_overlay.remove();
|
||||
if(exh_otext != null) exh_otext.remove();
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
val RECOGNIZE_JSON = """
|
||||
{
|
||||
"part_content_type": "audio/mp3",
|
||||
"keywords": [],
|
||||
"profanity_filter": false,
|
||||
"max_alternatives": 1,
|
||||
"speaker_labels": false,
|
||||
"firstReadyInSession": false,
|
||||
"preserveAdaptation": false,
|
||||
"timestamps": false,
|
||||
"inactivity_timeout": 30,
|
||||
"word_confidence": false,
|
||||
"audioMetrics": false,
|
||||
"latticeGeneration": true,
|
||||
"customGrammarWords": [],
|
||||
"action": "recognize"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val TRANSCRIPT_CLEANER_REGEX = Regex("[^0-9a-zA-Z_ -]")
|
||||
val SPACE_DEDUPE_REGEX = Regex(" +")
|
||||
|
||||
fun launch(context: Context,
|
||||
source: CaptchaCompletionVerifier,
|
||||
cookies: Map<String, String>,
|
||||
script: String,
|
||||
url: String) {
|
||||
url: String,
|
||||
autoSolveSubmitBtnSelector: String? = null) {
|
||||
val intent = Intent(context, SolveCaptchaActivity::class.java).apply {
|
||||
putExtra(SOURCE_ID_EXTRA, source.id)
|
||||
putExtra(COOKIES_EXTRA, HashMap(cookies))
|
||||
putExtra(SCRIPT_EXTRA, script)
|
||||
putExtra(URL_EXTRA, url)
|
||||
putExtra(ASBTN_EXTRA, autoSolveSubmitBtnSelector)
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
@ -115,6 +612,6 @@ class SolveCaptchaActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
interface CaptchaCompletionVerifier : Source {
|
||||
fun verify(url: String): Boolean
|
||||
fun verifyNoCaptcha(url: String): Boolean
|
||||
}
|
||||
|
||||
|
19
app/src/main/java/exh/ui/captcha/WebViewUtil.kt
Normal file
19
app/src/main/java/exh/ui/captcha/WebViewUtil.kt
Normal file
@ -0,0 +1,19 @@
|
||||
package exh.ui.captcha
|
||||
|
||||
import android.os.Build
|
||||
import android.support.annotation.RequiresApi
|
||||
import android.webkit.WebResourceRequest
|
||||
import okhttp3.Request
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
fun WebResourceRequest.toOkHttpRequest(): Request {
|
||||
val request = Request.Builder()
|
||||
.url(url.toString())
|
||||
.method(method, null)
|
||||
|
||||
requestHeaders.entries.forEach { (t, u) ->
|
||||
request.addHeader(t, u)
|
||||
}
|
||||
|
||||
return request.build()
|
||||
}
|
@ -11,8 +11,6 @@ import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import eu.kanade.tachiyomi.source.SourceManager
|
||||
import exh.isExSource
|
||||
import exh.isLewdSource
|
||||
import exh.metadata.queryMetadataFromManga
|
||||
import exh.util.defRealm
|
||||
import timber.log.Timber
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.concurrent.thread
|
||||
@ -29,54 +27,71 @@ class MetadataFetchDialog {
|
||||
//Too lazy to actually deal with orientation changes
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
|
||||
|
||||
var running = true
|
||||
|
||||
val progressDialog = MaterialDialog.Builder(context)
|
||||
.title("Fetching library metadata")
|
||||
.content("Preparing library")
|
||||
.progress(false, 0, true)
|
||||
.negativeText("Stop")
|
||||
.onNegative { dialog, which ->
|
||||
running = false
|
||||
dialog.dismiss()
|
||||
notifyMigrationStopped(context)
|
||||
}
|
||||
.cancelable(false)
|
||||
.canceledOnTouchOutside(false)
|
||||
.show()
|
||||
|
||||
thread {
|
||||
defRealm { realm ->
|
||||
db.deleteMangasNotInLibrary().executeAsBlocking()
|
||||
val libraryMangas = db.getLibraryMangas().executeAsBlocking()
|
||||
.filter { isLewdSource(it.source) }
|
||||
.distinctBy { it.id }
|
||||
|
||||
val libraryMangas = db.getLibraryMangas()
|
||||
.executeAsBlocking()
|
||||
.filter {
|
||||
isLewdSource(it.source)
|
||||
&& realm.queryMetadataFromManga(it).findFirst() == null
|
||||
context.runOnUiThread {
|
||||
progressDialog.maxProgress = libraryMangas.size
|
||||
}
|
||||
|
||||
val mangaWithMissingMetadata = libraryMangas
|
||||
.filterIndexed { index, libraryManga ->
|
||||
if(index % 100 == 0) {
|
||||
context.runOnUiThread {
|
||||
progressDialog.setContent("[Stage 1/2] Scanning for missing metadata...")
|
||||
progressDialog.setProgress(index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
context.runOnUiThread {
|
||||
progressDialog.maxProgress = libraryMangas.size
|
||||
}
|
||||
|
||||
//Actual metadata fetch code
|
||||
libraryMangas.forEachIndexed { i, manga ->
|
||||
context.runOnUiThread {
|
||||
progressDialog.setContent("Processing: ${manga.title}")
|
||||
progressDialog.setProgress(i + 1)
|
||||
db.getSearchMetadataForManga(libraryManga.id!!).executeAsBlocking() == null
|
||||
}
|
||||
try {
|
||||
val source = sourceManager.get(manga.source)
|
||||
source?.let {
|
||||
manga.copyFrom(it.fetchMangaDetails(manga).toBlocking().first())
|
||||
realm.queryMetadataFromManga(manga).findFirst()?.copyTo(manga)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Could not migrate manga!")
|
||||
}
|
||||
}
|
||||
.toList()
|
||||
|
||||
context.runOnUiThread {
|
||||
progressDialog.maxProgress = mangaWithMissingMetadata.size
|
||||
}
|
||||
|
||||
//Actual metadata fetch code
|
||||
for((i, manga) in mangaWithMissingMetadata.withIndex()) {
|
||||
if(!running) break
|
||||
context.runOnUiThread {
|
||||
progressDialog.dismiss()
|
||||
|
||||
//Enable orientation changes again
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
|
||||
|
||||
displayMigrationComplete(context)
|
||||
progressDialog.setContent("[Stage 2/2] Processing: ${manga.title}")
|
||||
progressDialog.setProgress(i + 1)
|
||||
}
|
||||
try {
|
||||
val source = sourceManager.get(manga.source)
|
||||
source?.let {
|
||||
manga.copyFrom(it.fetchMangaDetails(manga).toBlocking().first())
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "Could not migrate manga!")
|
||||
}
|
||||
}
|
||||
|
||||
context.runOnUiThread {
|
||||
progressDialog.dismiss()
|
||||
|
||||
//Enable orientation changes again
|
||||
context.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_NOSENSOR
|
||||
|
||||
if(running) displayMigrationComplete(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -85,7 +100,9 @@ class MetadataFetchDialog {
|
||||
var extra = ""
|
||||
db.getLibraryMangas().asRxSingle().subscribe {
|
||||
if(!explicit && it.none { isLewdSource(it.source) }) {
|
||||
//Do not open dialog on startup if no manga
|
||||
// Do not open dialog on startup if no manga
|
||||
// Also do not check again
|
||||
preferenceHelper.migrateLibraryAsked().set(true)
|
||||
} else {
|
||||
//Not logged in but have ExHentai galleries
|
||||
if (!preferenceHelper.enableExhentai().getOrDefault()) {
|
||||
@ -97,13 +114,14 @@ class MetadataFetchDialog {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title("Fetch library metadata")
|
||||
.content(Html.fromHtml("You need to fetch your library's metadata before tag searching in the library will function.<br><br>" +
|
||||
"This process may take a long time depending on your library size and will also use up a significant amount of internet bandwidth.<br><br>" +
|
||||
"This process may take a long time depending on your library size and will also use up a significant amount of internet bandwidth but can be stopped and started whenever you wish.<br><br>" +
|
||||
extra +
|
||||
"This process can be done later if required."))
|
||||
.positiveText("Migrate")
|
||||
.negativeText("Later")
|
||||
.onPositive { _, _ -> show(activity) }
|
||||
.onNegative({ _, _ -> adviseMigrationLater(activity) })
|
||||
.onNegative { _, _ -> adviseMigrationLater(activity) }
|
||||
.onAny { _, _ -> preferenceHelper.migrateLibraryAsked().set(true) }
|
||||
.cancelable(false)
|
||||
.canceledOnTouchOutside(false)
|
||||
.show()
|
||||
@ -124,6 +142,17 @@ class MetadataFetchDialog {
|
||||
.show()
|
||||
}
|
||||
|
||||
fun notifyMigrationStopped(activity: Activity) {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title("Metadata fetch stopped")
|
||||
.content("Library metadata fetch has been stopped.\n\n" +
|
||||
"You can continue this operation later by going to: Settings > Advanced > Migrate library metadata")
|
||||
.positiveText("Ok")
|
||||
.cancelable(true)
|
||||
.canceledOnTouchOutside(true)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun displayMigrationComplete(activity: Activity) {
|
||||
MaterialDialog.Builder(activity)
|
||||
.title("Migration complete")
|
||||
|
@ -1,81 +0,0 @@
|
||||
package exh.ui.migration
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||
import exh.isExSource
|
||||
import exh.isLewdSource
|
||||
import exh.metadata.models.ExGalleryMetadata
|
||||
import exh.util.realmTrans
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class UrlMigrator {
|
||||
private val db: DatabaseHelper by injectLazy()
|
||||
|
||||
private val prefs: PreferencesHelper by injectLazy()
|
||||
|
||||
fun perform() {
|
||||
db.inTransaction {
|
||||
val dbMangas = db.getMangas()
|
||||
.executeAsBlocking()
|
||||
|
||||
//Find all EX mangas
|
||||
val qualifyingMangas = dbMangas.asSequence().filter {
|
||||
isLewdSource(it.source)
|
||||
}
|
||||
|
||||
val possibleDups = mutableListOf<Manga>()
|
||||
val badMangas = mutableListOf<Manga>()
|
||||
|
||||
qualifyingMangas.forEach {
|
||||
if(it.url.startsWith("g/")) //Missing slash at front so we are bad
|
||||
badMangas.add(it)
|
||||
else
|
||||
possibleDups.add(it)
|
||||
}
|
||||
|
||||
//Sort possible dups so we can use binary search on it
|
||||
possibleDups.sortBy { it.url }
|
||||
|
||||
realmTrans { realm ->
|
||||
badMangas.forEach { manga ->
|
||||
//Build fixed URL
|
||||
val urlWithSlash = "/" + manga.url
|
||||
//Fix metadata if required
|
||||
val metadata = ExGalleryMetadata.UrlQuery(manga.url, isExSource(manga.source))
|
||||
.query(realm)
|
||||
.findFirst()
|
||||
metadata?.url?.let {
|
||||
if (it.startsWith("g/")) { //Check if metadata URL has no slash
|
||||
metadata.url = urlWithSlash //Fix it
|
||||
}
|
||||
}
|
||||
//If we have a dup (with the fixed url), use the dup instead
|
||||
val possibleDup = possibleDups.binarySearchBy(urlWithSlash, selector = { it.url })
|
||||
if (possibleDup >= 0) {
|
||||
//Make sure it is favorited if we are
|
||||
if (manga.favorite) {
|
||||
val dup = possibleDups[possibleDup]
|
||||
dup.favorite = true
|
||||
db.insertManga(dup).executeAsBlocking() //Update DB with changes
|
||||
}
|
||||
//Delete ourself (but the dup is still there)
|
||||
db.deleteManga(manga).executeAsBlocking()
|
||||
return@forEach
|
||||
}
|
||||
//No dup, correct URL and reinsert ourselves
|
||||
manga.url = urlWithSlash
|
||||
db.insertManga(manga).executeAsBlocking()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tryMigration() {
|
||||
if(!prefs.hasPerformedURLMigration().getOrDefault()) {
|
||||
perform()
|
||||
prefs.hasPerformedURLMigration().set(true)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,126 +0,0 @@
|
||||
package exh.ui.webview;
|
||||
|
||||
import android.content.Context;
|
||||
import android.support.v4.view.MotionEventCompat;
|
||||
import android.support.v4.view.NestedScrollingChild;
|
||||
import android.support.v4.view.NestedScrollingChildHelper;
|
||||
import android.support.v4.view.ViewCompat;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.webkit.WebView;
|
||||
|
||||
public class NestedWebView extends WebView implements NestedScrollingChild {
|
||||
private int mLastY;
|
||||
private final int[] mScrollOffset = new int[2];
|
||||
private final int[] mScrollConsumed = new int[2];
|
||||
private int mNestedOffsetY;
|
||||
private NestedScrollingChildHelper mChildHelper;
|
||||
|
||||
public NestedWebView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public NestedWebView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, android.R.attr.webViewStyle);
|
||||
}
|
||||
|
||||
public NestedWebView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
mChildHelper = new NestedScrollingChildHelper(this);
|
||||
setNestedScrollingEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent ev) {
|
||||
boolean returnValue = false;
|
||||
|
||||
MotionEvent event = MotionEvent.obtain(ev);
|
||||
final int action = MotionEventCompat.getActionMasked(event);
|
||||
if (action == MotionEvent.ACTION_DOWN) {
|
||||
mNestedOffsetY = 0;
|
||||
}
|
||||
int eventY = (int) event.getY();
|
||||
event.offsetLocation(0, mNestedOffsetY);
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
int deltaY = mLastY - eventY;
|
||||
// NestedPreScroll
|
||||
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
|
||||
deltaY -= mScrollConsumed[1];
|
||||
mLastY = eventY - mScrollOffset[1];
|
||||
event.offsetLocation(0, -mScrollOffset[1]);
|
||||
mNestedOffsetY += mScrollOffset[1];
|
||||
}
|
||||
returnValue = super.onTouchEvent(event);
|
||||
|
||||
// NestedScroll
|
||||
if (dispatchNestedScroll(0, mScrollOffset[1], 0, deltaY, mScrollOffset)) {
|
||||
event.offsetLocation(0, mScrollOffset[1]);
|
||||
mNestedOffsetY += mScrollOffset[1];
|
||||
mLastY -= mScrollOffset[1];
|
||||
}
|
||||
break;
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
returnValue = super.onTouchEvent(event);
|
||||
mLastY = eventY;
|
||||
// start NestedScroll
|
||||
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
returnValue = super.onTouchEvent(event);
|
||||
// end NestedScroll
|
||||
stopNestedScroll();
|
||||
break;
|
||||
}
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
// Nested Scroll implements
|
||||
@Override
|
||||
public void setNestedScrollingEnabled(boolean enabled) {
|
||||
mChildHelper.setNestedScrollingEnabled(enabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isNestedScrollingEnabled() {
|
||||
return mChildHelper.isNestedScrollingEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean startNestedScroll(int axes) {
|
||||
return mChildHelper.startNestedScroll(axes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stopNestedScroll() {
|
||||
mChildHelper.stopNestedScroll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNestedScrollingParent() {
|
||||
return mChildHelper.hasNestedScrollingParent();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
|
||||
int[] offsetInWindow) {
|
||||
return mChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
|
||||
return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
|
||||
return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
|
||||
return mChildHelper.dispatchNestedPreFling(velocityX, velocityY);
|
||||
}
|
||||
|
||||
}
|
@ -57,6 +57,10 @@ class WebViewActivity : BaseActivity() {
|
||||
webview.settings.javaScriptEnabled = true
|
||||
webview.settings.domStorageEnabled = true
|
||||
webview.settings.databaseEnabled = true
|
||||
webview.settings.useWideViewPort = true
|
||||
webview.settings.loadWithOverviewMode = true
|
||||
webview.settings.builtInZoomControls = true
|
||||
webview.settings.displayZoomControls = false
|
||||
webview.webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
@ -134,7 +138,6 @@ class WebViewActivity : BaseActivity() {
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
|
||||
menu?.findItem(R.id.action_forward)?.isEnabled = webview.canGoForward()
|
||||
menu?.findItem(R.id.action_desktop_site)?.isChecked = isDesktop
|
||||
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
@ -156,26 +159,6 @@ class WebViewActivity : BaseActivity() {
|
||||
android.R.id.home -> finish()
|
||||
R.id.action_refresh -> webview.reload()
|
||||
R.id.action_forward -> webview.goForward()
|
||||
R.id.action_desktop_site -> {
|
||||
isDesktop = !item.isChecked
|
||||
item.isChecked = isDesktop
|
||||
|
||||
(if(isDesktop) {
|
||||
mobileUserAgent?.replace("\\([^(]*(Mobile|Android)[^)]*\\)"
|
||||
.toRegex(RegexOption.IGNORE_CASE), "")
|
||||
?.replace("Mobile", "", true)
|
||||
?.replace("Android", "", true)
|
||||
} else {
|
||||
mobileUserAgent
|
||||
})?.let {
|
||||
webview.settings.userAgentString = it
|
||||
}
|
||||
|
||||
webview.settings.useWideViewPort = isDesktop
|
||||
webview.settings.loadWithOverviewMode = isDesktop
|
||||
|
||||
webview.reload()
|
||||
}
|
||||
R.id.action_open_in_browser ->
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(webview.url)))
|
||||
}
|
||||
|
27
app/src/main/java/exh/util/RxUtil.kt
Normal file
27
app/src/main/java/exh/util/RxUtil.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package exh.util
|
||||
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import rx.subjects.ReplaySubject
|
||||
|
||||
/**
|
||||
* Transform a cold single to a hot single
|
||||
*
|
||||
* Note: Behaves like a ReplaySubject
|
||||
* All generated items are buffered in memory!
|
||||
*/
|
||||
fun <T> Single<T>.melt(): Single<T> {
|
||||
return toObservable().melt().toSingle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a cold observable to a hot observable
|
||||
*
|
||||
* Note: Behaves like a ReplaySubject
|
||||
* All generated items are buffered in memory!
|
||||
*/
|
||||
fun <T> Observable<T>.melt(): Observable<T> {
|
||||
val rs = ReplaySubject.create<T>()
|
||||
subscribe(rs)
|
||||
return rs
|
||||
}
|
89
app/src/main/java/org/vepta/vdm/ByteCursor.kt
Normal file
89
app/src/main/java/org/vepta/vdm/ByteCursor.kt
Normal file
@ -0,0 +1,89 @@
|
||||
package org.vepta.vdm
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* Simple cursor for use on byte arrays
|
||||
* @author nulldev
|
||||
*/
|
||||
class ByteCursor(val content: ByteArray) {
|
||||
var index = -1
|
||||
private set
|
||||
private var mark = -1
|
||||
|
||||
fun mark() {
|
||||
mark = index
|
||||
}
|
||||
|
||||
fun jumpToMark() {
|
||||
index = mark
|
||||
}
|
||||
|
||||
fun jumpToIndex(index: Int) {
|
||||
this.index = index
|
||||
}
|
||||
|
||||
fun next(): Byte {
|
||||
return content[++index]
|
||||
}
|
||||
|
||||
fun next(count: Int): ByteArray {
|
||||
val res = content.sliceArray(index + 1 .. index + count)
|
||||
skip(count)
|
||||
return res
|
||||
}
|
||||
|
||||
//Used to perform conversions
|
||||
private fun byteBuffer(count: Int): ByteBuffer {
|
||||
return ByteBuffer.wrap(next(count))
|
||||
}
|
||||
|
||||
//Epic hack to get an unsigned short properly...
|
||||
fun fakeNextShortInt(): Int = ByteBuffer
|
||||
.wrap(arrayOf(0x00, 0x00, *next(2).toTypedArray()).toByteArray())
|
||||
.getInt(0)
|
||||
|
||||
// fun nextShort(): Short = byteBuffer(2).getShort(0)
|
||||
fun nextInt(): Int = byteBuffer(4).getInt(0)
|
||||
fun nextLong(): Long = byteBuffer(8).getLong(0)
|
||||
fun nextFloat(): Float = byteBuffer(4).getFloat(0)
|
||||
fun nextDouble(): Double = byteBuffer(8).getDouble(0)
|
||||
|
||||
fun skip(count: Int) {
|
||||
index += count
|
||||
}
|
||||
|
||||
fun expect(vararg bytes: Byte) {
|
||||
if(bytes.size > remaining())
|
||||
throw IllegalStateException("Unexpected end of content!")
|
||||
|
||||
for(i in 0 .. bytes.lastIndex) {
|
||||
val expected = bytes[i]
|
||||
val actual = content[index + i + 1]
|
||||
|
||||
if(expected != actual)
|
||||
throw IllegalStateException("Unexpected content (expected: $expected, actual: $actual)!")
|
||||
}
|
||||
|
||||
index += bytes.size
|
||||
}
|
||||
|
||||
fun checkEqual(vararg bytes: Byte): Boolean {
|
||||
if(bytes.size > remaining())
|
||||
return false
|
||||
|
||||
for(i in 0 .. bytes.lastIndex) {
|
||||
val expected = bytes[i]
|
||||
val actual = content[index + i + 1]
|
||||
|
||||
if(expected != actual)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun atEnd() = index >= content.size - 1
|
||||
|
||||
fun remaining() = content.size - index - 1
|
||||
}
|
9
app/src/main/res/drawable/ic_save_black_24dp.xml
Normal file
9
app/src/main/res/drawable/ic_save_black_24dp.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
|
||||
</vector>
|
@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
@ -11,27 +11,26 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:theme="?attr/actionBarTheme">
|
||||
android:theme="?attr/actionBarTheme"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<android.support.v7.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
app:layout_scrollFlags="scroll|enterAlways"
|
||||
android:layout_height="?attr/actionBarSize">
|
||||
|
||||
</android.support.v7.widget.Toolbar>
|
||||
</android.support.design.widget.AppBarLayout>
|
||||
|
||||
<android.support.v4.widget.NestedScrollView
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
app:layout_behavior="android.support.design.widget.AppBarLayout$ScrollingViewBehavior">
|
||||
|
||||
<exh.ui.webview.NestedWebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:isScrollContainer="false" />
|
||||
</android.support.v4.widget.NestedScrollView>
|
||||
</android.support.design.widget.CoordinatorLayout>
|
||||
android:layout_height="0dp"
|
||||
android:isScrollContainer="false"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/appbar" />
|
||||
</android.support.constraint.ConstraintLayout>
|
||||
|
@ -1,11 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
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:clickable="true"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/title_background"
|
||||
android:layout_width="match_parent"
|
||||
@ -41,19 +43,30 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?android:attr/divider"/>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="0"
|
||||
android:animateLayoutChanges="true"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/save_search_btn"
|
||||
style="@style/Theme.Widget.Button.Borderless"
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="0.25"
|
||||
android:tint="?attr/colorAccent"
|
||||
app:srcCompat="@drawable/ic_save_black_24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/reset_btn"
|
||||
style="@style/Theme.Widget.Button.Borderless"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_reset"/>
|
||||
android:layout_weight="0.75"
|
||||
android:text="@string/action_reset" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/search_btn"
|
||||
@ -61,6 +74,18 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/action_search"/>
|
||||
android:text="@string/action_search" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?android:attr/divider"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/saved_searches"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:animateLayoutChanges="true"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
@ -1,27 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<android.support.design.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
|
||||
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:fitsSystemWindows="true">
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<android.support.v7.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
<android.support.design.widget.AppBarLayout
|
||||
android:id="@+id/appbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:minHeight="?attr/actionBarSize"
|
||||
android:theme="?attr/actionBarTheme" />
|
||||
app:elevation="0dp">
|
||||
|
||||
<android.support.v7.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:theme="?attr/actionBarTheme"/>
|
||||
|
||||
</android.support.design.widget.AppBarLayout>
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@+id/toolbar"
|
||||
android:layout_below="@+id/appbar"
|
||||
android:layout_centerHorizontal="true" />
|
||||
</RelativeLayout>
|
||||
</android.support.design.widget.CoordinatorLayout>
|
@ -56,8 +56,7 @@
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?colorPrimary"
|
||||
android:elevation="4dp" />
|
||||
android:background="?colorPrimary" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/eh_utils"
|
||||
|
@ -9,10 +9,6 @@
|
||||
<item
|
||||
android:id="@+id/action_forward"
|
||||
android:title="Forward" />
|
||||
<item
|
||||
android:id="@+id/action_desktop_site"
|
||||
android:checkable="true"
|
||||
android:title="Desktop site" />
|
||||
<item
|
||||
android:id="@+id/action_open_in_browser"
|
||||
android:title="Open in browser" />
|
||||
|
@ -41,7 +41,7 @@
|
||||
</style>
|
||||
|
||||
<!-- EH Intercept Theme-->
|
||||
<style name="Theme.EHIntercept" parent="Theme.Tachiyomi">
|
||||
<style name="Theme.EHActivity" parent="Theme.Tachiyomi">
|
||||
<!-- Attributes specific for SDK 21 and up -->
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
|
||||
<item name="android:statusBarColor">@color/colorPrimaryDark</item>
|
||||
|
@ -47,7 +47,7 @@
|
||||
<!-- Attributes specific for SDK 16 to SDK 20 -->
|
||||
</style>
|
||||
|
||||
<style name="Theme.EHIntercept" parent="Theme.Tachiyomi">
|
||||
<style name="Theme.EHActivity" parent="Theme.Tachiyomi">
|
||||
<!-- Attributes specific for SDK 16 to SDK 20 -->
|
||||
</style>
|
||||
|
||||
|
@ -29,6 +29,7 @@ allprojects {
|
||||
google()
|
||||
maven { url "https://jitpack.io" }
|
||||
maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
|
||||
maven { url "https://dl.bintray.com/ibm-cloud-sdks/ibm-cloud-sdk-repo" }
|
||||
jcenter()
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
org.gradle.jvmargs=-Xmx8000M
|
||||
org.gradle.parallel=true
|
||||
org.gradle.jvmargs=-Xmx2000M
|
||||
android.enableBuildCache=true
|
||||
kotlin.incremental=true
|
Loading…
x
Reference in New Issue
Block a user