diff --git a/src/all/projectsuki/AndroidManifest.xml b/src/all/projectsuki/AndroidManifest.xml
new file mode 100644
index 000000000..867eb056f
--- /dev/null
+++ b/src/all/projectsuki/AndroidManifest.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/projectsuki/build.gradle b/src/all/projectsuki/build.gradle
new file mode 100644
index 000000000..5ea5dc348
--- /dev/null
+++ b/src/all/projectsuki/build.gradle
@@ -0,0 +1,15 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+
+ext {
+ extName = 'Project Suki'
+ pkgNameSuffix = 'all.projectsuki'
+ extClass = '.ProjectSuki'
+ extVersionCode = 1
+}
+
+dependencies {
+ implementation(project(":lib-randomua"))
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/projectsuki/res/mipmap-hdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..57b7a5296
Binary files /dev/null and b/src/all/projectsuki/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/projectsuki/res/mipmap-mdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..5b568e2ae
Binary files /dev/null and b/src/all/projectsuki/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/projectsuki/res/mipmap-xhdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..ac0b1b10d
Binary files /dev/null and b/src/all/projectsuki/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/projectsuki/res/mipmap-xxhdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..bd8d04b10
Binary files /dev/null and b/src/all/projectsuki/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/projectsuki/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/projectsuki/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..6aeb83f51
Binary files /dev/null and b/src/all/projectsuki/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/projectsuki/res/web_hi_res_512.png b/src/all/projectsuki/res/web_hi_res_512.png
new file mode 100644
index 000000000..83e3bb55a
Binary files /dev/null and b/src/all/projectsuki/res/web_hi_res_512.png differ
diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/NormalizedURL.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/NormalizedURL.kt
new file mode 100644
index 000000000..3f6840dde
--- /dev/null
+++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/NormalizedURL.kt
@@ -0,0 +1,54 @@
+package eu.kanade.tachiyomi.extension.all.projectsuki
+
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import org.jsoup.nodes.Element
+
+typealias NormalizedURL = HttpUrl
+
+val NormalizedURL.rawAbsolute: String
+ get() = toString()
+
+private val psDomainURI = """https://projectsuki.com/""".toHttpUrl().toUri()
+
+val NormalizedURL.rawRelative: String?
+ get() {
+ val uri = toUri()
+ return psDomainURI
+ .relativize(uri)
+ .takeIf { it != uri }
+ ?.let { """/$it""" }
+ }
+
+private val protocolMatcher = """^https?://""".toRegex()
+private val domainMatcher = """^https?://(?:[a-zA-Z\d\-]+\.)+[a-zA-Z\d\-]+""".toRegex()
+fun String.toNormalURL(): NormalizedURL? {
+ if (contains(':') && !contains(protocolMatcher)) {
+ return null
+ }
+
+ val toParse = StringBuilder()
+
+ if (!contains(domainMatcher)) {
+ toParse.append("https://projectsuki.com")
+ if (!this.startsWith("/")) toParse.append('/')
+ }
+
+ toParse.append(this)
+
+ return toParse.toString().toHttpUrlOrNull()
+}
+
+fun NormalizedURL.pathStartsWith(other: Iterable): Boolean = pathSegments.zip(other).all { (l, r) -> l == r }
+
+fun NormalizedURL.isPSUrl() = host.endsWith("${PS.identifier}.com")
+
+fun NormalizedURL.isBookURL() = isPSUrl() && pathSegments.first() == "book"
+fun NormalizedURL.isReadURL() = isPSUrl() && pathStartsWith(PS.chapterPath)
+fun NormalizedURL.isImagesGalleryURL() = isPSUrl() && pathStartsWith(PS.pagePath)
+
+fun Element.attrNormalizedUrl(attrName: String): NormalizedURL? {
+ val attrValue = attr("abs:$attrName").takeIf { it.isNotBlank() } ?: return null
+ return attrValue.toNormalURL()
+}
diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PS.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PS.kt
new file mode 100644
index 000000000..68c4dd935
--- /dev/null
+++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PS.kt
@@ -0,0 +1,129 @@
+@file:Suppress("MayBeConstant", "unused")
+
+package eu.kanade.tachiyomi.extension.all.projectsuki
+
+import org.jsoup.nodes.Element
+import java.util.Calendar
+import java.util.Locale
+import kotlin.concurrent.getOrSet
+
+@Suppress("MemberVisibilityCanBePrivate")
+internal object PS {
+ const val identifier: String = "projectsuki"
+ const val identifierShort: String = "ps"
+
+ val bookPath = listOf("book")
+ val pagePath = listOf("images", "gallery")
+ val chapterPath = listOf("read")
+
+ const val SEARCH_INTENT_PREFIX: String = "$identifierShort:"
+
+ const val PREFERENCE_WHITELIST_LANGUAGES = "$identifier-languages-whitelist"
+ const val PREFERENCE_WHITELIST_LANGUAGES_TITLE = "Whitelist the following languages:"
+ const val PREFERENCE_WHITELIST_LANGUAGES_SUMMARY =
+ "Will keep project chapters in the following languages." +
+ " Takes precedence over blacklisted languages." +
+ " It will match the string present in the \"Language\" column of the chapter." +
+ " Whitespaces will be trimmed." +
+ " Leave empty to allow all languages." +
+ " Separate each entry with a comma ','"
+
+ const val PREFERENCE_BLACKLIST_LANGUAGES = "$identifier-languages-blacklist"
+ const val PREFERENCE_BLACKLIST_LANGUAGES_TITLE = "Blacklist the following languages:"
+ const val PREFERENCE_BLACKLIST_LANGUAGES_SUMMARY =
+ "Will hide project chapters in the following languages." +
+ " Works identically to whitelisting."
+}
+
+fun Element.containsBookLinks(): Boolean = select("a").any {
+ it.attrNormalizedUrl("href")?.isBookURL() == true
+}
+
+fun Element.containsReadLinks(): Boolean = select("a").any {
+ it.attrNormalizedUrl("href")?.isReadURL() == true
+}
+
+fun Element.containsImageGalleryLinks(): Boolean = select("a").any {
+ it.attrNormalizedUrl("href")?.isImagesGalleryURL() == true
+}
+
+fun Element.getAllUrlElements(selector: String, attrName: String, predicate: (NormalizedURL) -> Boolean): Map {
+ return select(selector)
+ .mapNotNull { element -> element.attrNormalizedUrl(attrName)?.let { element to it } }
+ .filter { (_, url) -> predicate(url) }
+ .toMap()
+}
+
+fun Element.getAllBooks(): Map {
+ val bookUrls = getAllUrlElements("a", "href") { it.isBookURL() }
+ val byID: Map> = bookUrls.groupBy { (_, url) -> url.pathSegments[1] /* /book/ */ }
+
+ @Suppress("UNCHECKED_CAST")
+ return byID.mapValues { (bookid, elements) ->
+ val thumb: Element? = elements.entries.firstNotNullOfOrNull { (element, _) ->
+ element.select("img").firstOrNull()
+ }
+ val title = elements.entries.firstOrNull { (element, _) ->
+ element.select("img").isEmpty() && element.text().let {
+ it.isNotBlank() && it.lowercase(Locale.US) != "show more"
+ }
+ }
+
+ if (thumb != null && title != null) {
+ PSBook(thumb, title.key, title.key.text(), bookid, title.value)
+ } else {
+ null
+ }
+ }.filterValues { it != null } as Map
+}
+
+inline fun Map.groupBy(keySelector: (Map.Entry) -> SK): Map> = buildMap<_, MutableMap> {
+ this@groupBy.entries.forEach { entry ->
+ getOrPut(keySelector(entry)) { HashMap() }[entry.key] = entry.value
+ }
+}
+
+private val absoluteDateFormat: ThreadLocal = ThreadLocal()
+fun String.parseDate(ifFailed: Long = 0L): Long {
+ return when {
+ endsWith("ago") -> {
+ // relative
+ val number = takeWhile { it.isDigit() }.toInt()
+ val cal = Calendar.getInstance()
+
+ when {
+ contains("day") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number) }
+ contains("hour") -> cal.apply { add(Calendar.HOUR, -number) }
+ contains("minute") -> cal.apply { add(Calendar.MINUTE, -number) }
+ contains("second") -> cal.apply { add(Calendar.SECOND, -number) }
+ contains("week") -> cal.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }
+ contains("month") -> cal.apply { add(Calendar.MONTH, -number) }
+ contains("year") -> cal.apply { add(Calendar.YEAR, -number) }
+ else -> null
+ }?.timeInMillis ?: ifFailed
+ }
+
+ else -> {
+ // absolute?
+ absoluteDateFormat.getOrSet { java.text.SimpleDateFormat("MMMM dd, yyyy", Locale.US) }.parse(this)?.time ?: ifFailed
+ }
+ }
+}
+
+private val imageExtensions = setOf(".jpg", ".png", ".jpeg", ".webp", ".gif", ".avif", ".tiff")
+private val simpleSrcVariants = listOf("src", "data-src", "data-lazy-src")
+fun Element.imgNormalizedURL(): NormalizedURL? {
+ simpleSrcVariants.forEach { variant ->
+ if (hasAttr(variant)) {
+ return attrNormalizedUrl(variant)
+ }
+ }
+
+ if (hasAttr("srcset")) {
+ return attr("abs:srcset").substringBefore(" ").toNormalURL()
+ }
+
+ return attributes().firstOrNull {
+ it.key.contains("src") && imageExtensions.any { ext -> it.value.contains(ext) }
+ }?.value?.substringBefore(" ")?.toNormalURL()
+}
diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSBook.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSBook.kt
new file mode 100644
index 000000000..87198dee1
--- /dev/null
+++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSBook.kt
@@ -0,0 +1,11 @@
+package eu.kanade.tachiyomi.extension.all.projectsuki
+
+import org.jsoup.nodes.Element
+
+data class PSBook(
+ val imgElement: Element,
+ val titleElement: Element,
+ val title: String,
+ val mangaID: String,
+ val url: NormalizedURL,
+)
diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSFilters.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSFilters.kt
new file mode 100644
index 000000000..dfa2d1646
--- /dev/null
+++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PSFilters.kt
@@ -0,0 +1,90 @@
+@file:Suppress("CanSealedSubClassBeObject")
+
+package eu.kanade.tachiyomi.extension.all.projectsuki
+
+import eu.kanade.tachiyomi.source.model.Filter
+import okhttp3.HttpUrl
+
+@Suppress("NOTHING_TO_INLINE")
+object PSFilters {
+ internal sealed interface AutoFilter {
+ fun applyTo(builder: HttpUrl.Builder)
+ }
+
+ private inline fun HttpUrl.Builder.setAdv() = setQueryParameter("adv", "1")
+
+ class Author : Filter.Text("Author"), AutoFilter {
+
+ override fun applyTo(builder: HttpUrl.Builder) {
+ when {
+ state.isNotBlank() -> builder.setAdv().addQueryParameter("author", state)
+ }
+ }
+
+ companion object {
+ val ownHeader by lazy { Header("Cannot search by multiple authors") }
+ }
+ }
+
+ class Artist : Filter.Text("Artist"), AutoFilter {
+
+ override fun applyTo(builder: HttpUrl.Builder) {
+ when {
+ state.isNotBlank() -> builder.setAdv().addQueryParameter("artist", state)
+ }
+ }
+
+ companion object {
+ val ownHeader by lazy { Header("Cannot search by multiple artists") }
+ }
+ }
+
+ class Status : Filter.Select("Status", Value.values()), AutoFilter {
+ enum class Value(val display: String, val query: String) {
+ ANY("Any", ""),
+ ONGOING("Ongoing", "ongoing"),
+ COMPLETED("Completed", "completed"),
+ HIATUS("Hiatus", "hiatus"),
+ CANCELLED("Cancelled", "cancelled"),
+ ;
+
+ override fun toString(): String = display
+
+ companion object {
+ private val values: Array = values()
+ operator fun get(ordinal: Int) = values[ordinal]
+ }
+ }
+
+ override fun applyTo(builder: HttpUrl.Builder) {
+ when (val state = Value[state]) {
+ Value.ANY -> {} // default, do nothing
+ else -> builder.setAdv().addQueryParameter("status", state.query)
+ }
+ }
+ }
+
+ class Origin : Filter.Select("Origin", Value.values()), AutoFilter {
+ enum class Value(val display: String, val query: String?) {
+ ANY("Any", null),
+ KOREA("Korea", "kr"),
+ CHINA("China", "cn"),
+ JAPAN("Japan", "jp"),
+ ;
+
+ override fun toString(): String = display
+
+ companion object {
+ private val values: Array = Value.values()
+ operator fun get(ordinal: Int) = values[ordinal]
+ }
+ }
+
+ override fun applyTo(builder: HttpUrl.Builder) {
+ when (val state = Value[state]) {
+ Value.ANY -> {} // default, do nothing
+ else -> builder.setAdv().addQueryParameter("origin", state.query)
+ }
+ }
+ }
+}
diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSuki.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSuki.kt
new file mode 100644
index 000000000..0dbf62fc6
--- /dev/null
+++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSuki.kt
@@ -0,0 +1,443 @@
+package eu.kanade.tachiyomi.extension.all.projectsuki
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.preference.EditTextPreference
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.lib.randomua.addRandomUAPreferenceToScreen
+import eu.kanade.tachiyomi.lib.randomua.getPrefCustomUA
+import eu.kanade.tachiyomi.lib.randomua.getPrefUAType
+import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.network.interceptor.rateLimit
+import eu.kanade.tachiyomi.source.ConfigurableSource
+import eu.kanade.tachiyomi.source.model.Filter
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.model.UpdateStrategy
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.util.Locale
+
+@Suppress("unused")
+class ProjectSuki : HttpSource(), ConfigurableSource {
+ override val name: String = "Project Suki"
+ override val baseUrl: String = "https://projectsuki.com"
+ override val lang: String = "en"
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ private fun String.processLangPref(): List = split(",").map { it.trim().lowercase(Locale.US) }
+
+ private val SharedPreferences.whitelistedLanguages: List
+ get() = getString(PS.PREFERENCE_WHITELIST_LANGUAGES, "")!!
+ .processLangPref()
+
+ private val SharedPreferences.blacklistedLanguages: List
+ get() = getString(PS.PREFERENCE_BLACKLIST_LANGUAGES, "")!!
+ .processLangPref()
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ addRandomUAPreferenceToScreen(screen)
+
+ screen.addPreference(
+ EditTextPreference(screen.context).apply {
+ key = PS.PREFERENCE_WHITELIST_LANGUAGES
+ title = PS.PREFERENCE_WHITELIST_LANGUAGES_TITLE
+ summary = PS.PREFERENCE_WHITELIST_LANGUAGES_SUMMARY
+ },
+ )
+
+ screen.addPreference(
+ EditTextPreference(screen.context).apply {
+ key = PS.PREFERENCE_BLACKLIST_LANGUAGES
+ title = PS.PREFERENCE_BLACKLIST_LANGUAGES_TITLE
+ summary = PS.PREFERENCE_BLACKLIST_LANGUAGES_SUMMARY
+ },
+ )
+ }
+
+ override val client: OkHttpClient = network.cloudflareClient.newBuilder()
+ .setRandomUserAgent(
+ userAgentType = preferences.getPrefUAType(),
+ customUA = preferences.getPrefCustomUA(),
+ filterInclude = listOf("chrome"),
+ )
+ .rateLimit(4)
+ .build()
+
+ override fun popularMangaRequest(page: Int) = GET(baseUrl, headers)
+
+ // differentiating between popular and latest manga in the main page is
+ // *theoretically possible* but a pain, as such, this is fine "for now"
+ override fun popularMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val allBooks = document.getAllBooks()
+ return MangasPage(
+ mangas = allBooks.mapNotNull mangas@{ (_, psbook) ->
+ val (img, _, titleText, _, url) = psbook
+
+ val relativeUrl = url.rawRelative ?: return@mangas null
+
+ SManga.create().apply {
+ this.url = relativeUrl
+ this.title = titleText
+ this.thumbnail_url = img.imgNormalizedURL()?.rawAbsolute
+ }
+ },
+ hasNextPage = false,
+ )
+ }
+
+ override val supportsLatest: Boolean = false
+ override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException()
+ override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException()
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return when {
+ /*query.startsWith(PS.SEARCH_INTENT_PREFIX) -> {
+ val id = query.substringAfter(PS.SEARCH_INTENT_PREFIX)
+ client.newCall(getMangaByIdAsSearchResult(id))
+ .asObservableSuccess()
+ .map { response -> searchMangaParse(response) }
+ }*/
+
+ else -> Observable.defer {
+ try {
+ client.newCall(searchMangaRequest(page, query, filters))
+ .asObservableSuccess()
+ } catch (e: NoClassDefFoundError) {
+ throw RuntimeException(e)
+ }
+ }.map { response -> searchMangaParse(response) }
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ return GET(
+ baseUrl.toHttpUrl().newBuilder().apply {
+ addPathSegment("search")
+ addQueryParameter("page", (page - 1).toString())
+ addQueryParameter("q", query)
+
+ filters.applyFilter(this)
+ filters.applyFilter(this)
+ filters.applyFilter(this)
+ filters.applyFilter(this)
+ }.build(),
+ headers,
+ )
+ }
+
+ private inline fun FilterList.applyFilter(to: HttpUrl.Builder) where T : Filter<*>, T : PSFilters.AutoFilter {
+ firstNotNullOfOrNull { it as? T }?.applyTo(to)
+ }
+
+ override fun getFilterList() = FilterList(
+ Filter.Header("Filters only take effect when searching for something!"),
+ PSFilters.Origin(),
+ PSFilters.Status(),
+ PSFilters.Author.ownHeader,
+ PSFilters.Author(),
+ PSFilters.Artist.ownHeader,
+ PSFilters.Artist(),
+ )
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val document = response.asJsoup()
+ val allBooks = document.getAllBooks()
+
+ val mangas = allBooks.mapNotNull mangas@{ (_, psbook) ->
+ val (img, _, titleText, _, url) = psbook
+
+ val relativeUrl = url.rawRelative ?: return@mangas null
+
+ SManga.create().apply {
+ this.url = relativeUrl
+ this.title = titleText
+ this.thumbnail_url = img.imgNormalizedURL()?.rawAbsolute
+ }
+ }
+
+ return MangasPage(
+ mangas = mangas,
+ hasNextPage = mangas.size >= 30, // observed max number of results in search
+ )
+ }
+
+ override fun fetchMangaDetails(manga: SManga): Observable {
+ return client.newCall(mangaDetailsRequest(manga))
+ .asObservableSuccess()
+ .map { response ->
+ mangaDetailsParse(response, incomplete = manga).apply { initialized = true }
+ }
+ }
+
+ private val displayNoneMatcher = """display: ?none;""".toRegex()
+ private val emptyImageURLAbsolute = """https://projectsuki.com/images/gallery/empty.jpg""".toNormalURL()!!.rawAbsolute
+ private val emptyImageURLRelative = """https://projectsuki.com/images/gallery/empty.jpg""".toNormalURL()!!.rawRelative!!
+ override fun mangaDetailsParse(response: Response): SManga = throw UnsupportedOperationException("not used")
+ private fun mangaDetailsParse(response: Response, incomplete: SManga): SManga {
+ val document = response.asJsoup()
+ val allLinks = document.getAllUrlElements("a", "href") { it.isPSUrl() }
+
+ val thumb: Element? = document.select("img").firstOrNull { img ->
+ img.attr("onerror").let {
+ it.contains(emptyImageURLAbsolute) ||
+ it.contains(emptyImageURLRelative)
+ }
+ }
+
+ val authors: Map = allLinks.filter { (_, url) ->
+ url.queryParameterNames.contains("author")
+ }
+
+ val artists: Map = allLinks.filter { (_, url) ->
+ url.queryParameterNames.contains("artist")
+ }
+
+ val statuses: Map = allLinks.filter { (_, url) ->
+ url.queryParameterNames.contains("status")
+ }
+
+ val origins: Map = allLinks.filter { (_, url) ->
+ url.queryParameterNames.contains("origin")
+ }
+
+ val genres: Map = allLinks.filter { (_, url) ->
+ url.pathStartsWith(listOf("genre"))
+ }
+
+ val description = document.select("#descriptionCollapse").joinToString("\n-----\n", postfix = "\n") { it.wholeText() }
+
+ val alerts = document.select(".alert, .alert-info")
+ .filter(
+ predicate = {
+ it.parents().none { parent ->
+ parent.attr("style")
+ .contains(displayNoneMatcher)
+ }
+ },
+ )
+
+ val userRating = document.select("#ratings")
+ .firstOrNull()
+ ?.children()
+ ?.count { it.hasClass("text-warning") }
+ ?.takeIf { it > 0 }
+
+ return SManga.create().apply {
+ url = incomplete.url
+ title = incomplete.title
+ thumbnail_url = thumb?.imgNormalizedURL()?.rawAbsolute ?: incomplete.thumbnail_url
+
+ author = authors.keys.joinToString(", ") { it.text() }
+ artist = artists.keys.joinToString(", ") { it.text() }
+ status = when (statuses.keys.joinToString("") { it.text().trim() }.lowercase(Locale.US)) {
+ "ongoing" -> SManga.ONGOING
+ "completed" -> SManga.PUBLISHING_FINISHED
+ "hiatus" -> SManga.ON_HIATUS
+ "cancelled" -> SManga.CANCELLED
+ else -> SManga.UNKNOWN
+ }
+
+ this.description = buildString {
+ if (alerts.isNotEmpty()) {
+ appendLine("Alerts have been found, refreshing the manga later might help in removing them.")
+ appendLine()
+
+ alerts.forEach { alert ->
+ var appendedSomething = false
+ alert.select("h4").singleOrNull()?.let {
+ appendLine(it.text())
+ appendedSomething = true
+ }
+ alert.select("p").singleOrNull()?.let {
+ appendLine(it.text())
+ appendedSomething = true
+ }
+ if (!appendedSomething) {
+ appendLine(alert.text())
+ }
+ }
+
+ appendLine()
+ appendLine()
+ }
+
+ appendLine(description)
+
+ fun appendToDescription(by: String, data: String?) {
+ if (data != null) append(by).appendLine(data)
+ }
+
+ appendToDescription("User Rating: ", """${userRating ?: "?"}/5""")
+ appendToDescription("Authors: ", author)
+ appendToDescription("Artists: ", artist)
+ appendToDescription("Status: ", statuses.keys.joinToString(", ") { it.text() })
+ appendToDescription("Origin: ", origins.keys.joinToString(", ") { it.text() })
+ appendToDescription("Genres: ", genres.keys.joinToString(", ") { it.text() })
+ }
+
+ this.update_strategy = if (status != SManga.CANCELLED) UpdateStrategy.ALWAYS_UPDATE else UpdateStrategy.ONLY_FETCH_ONCE
+ this.genre = buildList {
+ addAll(genres.keys.map { it.text() })
+ origins.values.forEach { url ->
+ when (url.queryParameter("origin")) {
+ "kr" -> add("Manhwa")
+ "cn" -> add("Manhua")
+ "jp" -> add("Manga")
+ }
+ }
+ }.joinToString(", ")
+ }
+ }
+
+ private val chapterHeaderMatcher = """chapters?""".toRegex()
+ private val groupHeaderMatcher = """groups?""".toRegex()
+ private val dateHeaderMatcher = """added|date""".toRegex()
+ private val languageHeaderMatcher = """language""".toRegex()
+ private val chapterNumberMatcher = """[Cc][Hh][Aa][Pp][Tt][Ee][Rr]\s*(\d+)(?:\s*[.,-]\s*(\d+))?""".toRegex()
+ private val looseNumberMatcher = """(\d+)(?:\s*[.,-]\s*(\d+))?""".toRegex()
+ override fun chapterListParse(response: Response): List {
+ val document = response.asJsoup()
+ val chaptersTable = document.select("table").firstOrNull { it.containsReadLinks() } ?: return emptyList()
+
+ val thead: Element = chaptersTable.select("thead").firstOrNull() ?: return emptyList()
+ val tbody: Element = chaptersTable.select("tbody").firstOrNull() ?: return emptyList()
+
+ val columnTypes = thead.select("tr").firstOrNull()?.children()?.select("td") ?: return emptyList()
+ val textTypes = columnTypes.map { it.text().lowercase(Locale.US) }
+ val normalSize = textTypes.size
+
+ val chaptersIndex: Int = textTypes.indexOfFirst { it.matches(chapterHeaderMatcher) }.takeIf { it >= 0 } ?: return emptyList()
+ val dateIndex: Int = textTypes.indexOfFirst { it.matches(dateHeaderMatcher) }.takeIf { it >= 0 } ?: return emptyList()
+ val groupIndex: Int? = textTypes.indexOfFirst { it.matches(groupHeaderMatcher) }.takeIf { it >= 0 }
+ val languageIndex: Int? = textTypes.indexOfFirst { it.matches(languageHeaderMatcher) }.takeIf { it >= 0 }
+
+ val dataRows = tbody.children().select("tr")
+
+ val blLangs = preferences.blacklistedLanguages
+ val wlLangs = preferences.whitelistedLanguages
+
+ return dataRows.mapNotNull chapters@{ tr ->
+ val rowData = tr.children().select("td")
+
+ if (rowData.size != normalSize) {
+ return@chapters null
+ }
+
+ val chapter: Element = rowData[chaptersIndex]
+ val date: Element = rowData[dateIndex]
+ val group: Element? = groupIndex?.let(rowData::get)
+ val language: Element? = languageIndex?.let(rowData::get)
+
+ language?.text()?.lowercase(Locale.US)?.let { lang ->
+ if (lang in blLangs && lang !in wlLangs) return@chapters null
+ }
+
+ val chapterLink = chapter.select("a").first()!!.attrNormalizedUrl("href")!!
+
+ val relativeURL = chapterLink.rawRelative ?: return@chapters null
+
+ SChapter.create().apply {
+ chapter_number = chapter.text()
+ .let { (chapterNumberMatcher.find(it) ?: looseNumberMatcher.find(it)) }
+ ?.let { result ->
+ val integral = result.groupValues[1]
+ val fractional = result.groupValues.getOrNull(2)
+
+ """${integral}$fractional""".toFloat()
+ } ?: -1f
+
+ url = relativeURL
+ scanlator = group?.text() ?: ""
+ name = chapter.text()
+ date_upload = date.text().parseDate()
+ }
+ }.toList()
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used")
+
+ private val callpageUrl = """https://projectsuki.com/callpage"""
+ private val jsonMediaType = "application/json;charset=UTF-8".toMediaType()
+ override fun fetchPageList(chapter: SChapter): Observable> {
+ // chapter.url is /read///...
+ val url = chapter.url.toNormalURL() ?: return Observable.just(emptyList())
+
+ val bookid = url.pathSegments[1] //
+ val chapterid = url.pathSegments[2] //
+
+ val callpageHeaders = headersBuilder()
+ .add("X-Requested-With", "XMLHttpRequest")
+ .add("Content-Type", "application/json;charset=UTF-8")
+ .build()
+
+ val callpageBody = Json.encodeToString(
+ mapOf(
+ "bookid" to bookid,
+ "chapterid" to chapterid,
+ "first" to "true",
+ ),
+ ).toRequestBody(jsonMediaType)
+
+ return client.newCall(
+ POST(callpageUrl, callpageHeaders, callpageBody),
+ ).asObservableSuccess()
+ .map { response ->
+ callpageParse(chapter, response)
+ }
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private fun callpageParse(chapter: SChapter, response: Response): List {
+ // response contains the html src with images
+ val src = Json.parseToJsonElement(response.body.string()).jsonObject["src"]?.jsonPrimitive?.content ?: return emptyList()
+ val images = Jsoup.parseBodyFragment(src).select("img")
+ // images urls are /images/gallery///? (empty query for some reason)
+ val urls = images.mapNotNull { it.attrNormalizedUrl("src") }
+ if (urls.isEmpty()) return emptyList()
+
+ val anUrl = urls.random()
+ val pageNums = urls.mapTo(ArrayList()) { it.pathSegments[4] }
+ pageNums += "001"
+
+ fun makeURL(pageNum: String) = anUrl.newBuilder()
+ .setPathSegment(anUrl.pathSegments.lastIndex, pageNum)
+ .build()
+
+ return pageNums.distinct().sortedBy { it.toInt() }.mapIndexed { index, number ->
+ Page(
+ index,
+ "",
+ makeURL(number).rawAbsolute,
+ )
+ }.distinctBy { it.imageUrl }
+ }
+
+ override fun pageListParse(response: Response): List = throw UnsupportedOperationException("not used")
+}
diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiUrlActivity.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiUrlActivity.kt
new file mode 100644
index 000000000..a72a020b1
--- /dev/null
+++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiUrlActivity.kt
@@ -0,0 +1,33 @@
+package eu.kanade.tachiyomi.extension.all.projectsuki
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class ProjectSukiUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+ if (pathSegments != null && pathSegments.size > 1) {
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${PS.SEARCH_INTENT_PREFIX}${pathSegments[1]}")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("PSUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("PSUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}