From 40c354f4d0d61e2d41c5e0f6db97c9b475905587 Mon Sep 17 00:00:00 2001 From: Federico d'Alonzo Date: Sun, 31 Dec 2023 22:57:50 +0100 Subject: [PATCH] Project Suki: refactors and fixes (#19513) * refactor(reportErrorToUser): enhance reportErrorToUser * refactor(DataExtractor): add unexpectedErrorCatchingLazy Makes it easier to quickly find and fix unexpected errors * refactor(BookDetail): attempt at making BookDetail more extensible Also fixes a rare bug that would throw a NoSuchElementException when the status or origin fields weren't found in the details table. * refactor(mangaDetailsParse): refactor mangaDetailsParse to follow BookDetail's refactor * chore(reportErrorToUser): Review reportErrorToUser messages * refactor(Search): completely separate simple and smart search create SmartBookSearchHandler as an attempt to speed up search by wasting less resources on unnecessary multiple normalization and reinitialization of resources via ThreadLocal * chore(build): bumped extVersionCode to 3 * refactor(activities): Add activities to handle /book and /read URLs Create a MangasPage with only a single Manga present (unfortunately needs to fetch manga details as title can't be inferred just by bookid) Group activities in "activities" package for clarity * fix(KDoc): fix Cannot resolve symbol * chore: Update README and CHANGELOG * chore: Add a bit of documentation to SmartBookSearchHandler * feat: Handle /book and /read urls as search query * chore(CHANGELOG): entries incorrectly listed as PUBLISHING_FINISHED now are correctly listed as COMPLETED * chore(README): expanded README --- src/all/projectsuki/AndroidManifest.xml | 41 ++- src/all/projectsuki/CHANGELOG.md | 9 + src/all/projectsuki/README.md | 15 +- src/all/projectsuki/build.gradle | 2 +- .../all/projectsuki/DataExtractor.kt | 282 ++++++++++++------ .../extension/all/projectsuki/PathPattern.kt | 8 +- .../extension/all/projectsuki/ProjectSuki.kt | 146 +++++++-- .../all/projectsuki/ProjectSukiAPI.kt | 163 ++-------- .../all/projectsuki/SmartBookSearchHandler.kt | 223 ++++++++++++++ .../activities/ProjectSukiBookUrlActivity.kt | 74 +++++ .../activities/ProjectSukiReadUrlActivity.kt | 74 +++++ .../ProjectSukiSearchUrlActivity.kt | 23 +- 12 files changed, 788 insertions(+), 272 deletions(-) create mode 100644 src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/SmartBookSearchHandler.kt create mode 100644 src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiBookUrlActivity.kt create mode 100644 src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiReadUrlActivity.kt rename src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/{ => activities}/ProjectSukiSearchUrlActivity.kt (69%) diff --git a/src/all/projectsuki/AndroidManifest.xml b/src/all/projectsuki/AndroidManifest.xml index 6a117da70..6e48623a7 100644 --- a/src/all/projectsuki/AndroidManifest.xml +++ b/src/all/projectsuki/AndroidManifest.xml @@ -2,9 +2,10 @@ - + + @@ -30,5 +31,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/all/projectsuki/CHANGELOG.md b/src/all/projectsuki/CHANGELOG.md index b36c92efd..a7f0bfac2 100644 --- a/src/all/projectsuki/CHANGELOG.md +++ b/src/all/projectsuki/CHANGELOG.md @@ -1,3 +1,12 @@ +## Version 1.4.3 + +- entries incorrectly listed as PUBLISHING_FINISHED now are correctly listed as COMPLETED +- fixes a rare bug that would throw a NoSuchElementException when the status or origin fields + weren't found in the details table. +- separate Smart and Simple search in code to make it easier to debug and extend it in the future +- add activities to handle /book and /read URLs (search/ was already present) +- handle /book and /read urls as search query + ## Version 1.4.2 - Improved search feature diff --git a/src/all/projectsuki/README.md b/src/all/projectsuki/README.md index 5d44d2754..68ee13749 100644 --- a/src/all/projectsuki/README.md +++ b/src/all/projectsuki/README.md @@ -1,9 +1,22 @@ # Project Suki +### Issues + Go check out our general FAQs and Guides over at [Extension FAQ](https://tachiyomi.org/help/faq/#extensions) or [Getting Started](https://tachiyomi.org/help/guides/getting-started/#installation). If you still don't find the answer you're looking for you're welcome to open an [issue](https://github.com/tachiyomiorg/tachiyomi-extensions/issues) -and mention [me](https://github.com/npgx/) *in the issue*. +**if one isn't open already** and mention [me](https://github.com/npgx/) *in the issue*. + +### Usage Tips + +- If you know the bookID of a manga you want to search for, you can search for it directly by typing + `book/` in the search bar in the Tachiyomi app, this works for `read/` too, with + or without `http(s)://projectsuki.com` + (you can copy the link of a book or chapter into the search bar). +- Inside the extension settings (Extensions > ProjectSuki > Gear icon right of "Multi") you can + whitelist and blacklist languages, instructions are provided there. + There you can also provide a custom + [User-Agent](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) if necessary. diff --git a/src/all/projectsuki/build.gradle b/src/all/projectsuki/build.gradle index def93d091..6106db02c 100644 --- a/src/all/projectsuki/build.gradle +++ b/src/all/projectsuki/build.gradle @@ -6,7 +6,7 @@ ext { extName = 'Project Suki' pkgNameSuffix = 'all.projectsuki' extClass = '.ProjectSuki' - extVersionCode = 2 + extVersionCode = 3 } dependencies { diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/DataExtractor.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/DataExtractor.kt index f9b775a70..017fafa67 100644 --- a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/DataExtractor.kt +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/DataExtractor.kt @@ -9,9 +9,9 @@ import org.jsoup.select.Elements import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date -import java.util.EnumMap import java.util.Locale import java.util.TimeZone +import kotlin.properties.PropertyDelegateProvider /** * @see EXTENSION_INFO Found in ProjectSuki.kt @@ -23,6 +23,34 @@ internal typealias BookID = String internal typealias ChapterID = String internal typealias ScanGroup = String +/** + * Creates a [delegate provider](https://kotlinlang.org/docs/delegated-properties.html#providing-a-delegate) + * that will return a [Lazy] where the [initializer] is wrapped by a try/catch block that will catch all exceptions + * that aren't a [ProjectSukiException] and constructing a [reportErrorToUser] with a locationHint. + */ +internal fun unexpectedErrorCatchingLazy(mode: LazyThreadSafetyMode = LazyThreadSafetyMode.SYNCHRONIZED, initializer: () -> R): PropertyDelegateProvider> { + return PropertyDelegateProvider { thisRef, property -> + lazy(mode) { + try { + initializer() + } catch (exception: Exception) { + if (exception !is ProjectSukiException) { + val locationHint = buildString { + when (thisRef) { + null -> append("") + else -> append(thisRef::class.simpleName) + } + append('.') + append(property.name) + } + reportErrorToUser(locationHint) { """Unexpected ${exception::class.simpleName}: ${exception.message ?: ""}""" } + } + throw exception + } + } + } +} + /** * Gets the thumbnail image for a particular [bookID], [extension] if needed and [size]. * @@ -45,14 +73,14 @@ internal fun bookThumbnailUrl(bookID: BookID, extension: String, size: UInt? = n } /** - * Finds the closest common parent between 2 or more [elements]. + * Finds the nearest common parent between 2 or more [elements] (will return null if [elements].size < 2). * * If all [elements] are the same element, it will return the element itself. * - * Returns null if the [elements] are not in the same [Document]. + * Returns null if the [elements] are not in the same hierarchy (no common parent, e.g. not in the same [Document]). */ -internal fun commonParent(vararg elements: Element): Element? { - require(elements.size > 1) { "elements must have more than 1 element" } +internal fun nearestCommonParent(elements: Collection): Element? { + if (elements.size < 2) return null val parents: List> = elements.map { it.parents().reversed().iterator() } var lastCommon: Element? = null @@ -81,14 +109,10 @@ internal fun commonParent(vararg elements: Element): Element? { internal data class SwitchingPoint(val left: Int, val right: Int, val leftState: Boolean, val rightState: Boolean) { init { if (left + 1 != right) { - reportErrorToUser { - "invalid SwitchingPoint: ($left, $right)" - } + reportErrorToUser { "invalid SwitchingPoint: ($left, $right)" } } if (leftState == rightState) { - reportErrorToUser { - "invalid SwitchingPoint: ($leftState, $rightState)" - } + reportErrorToUser { "invalid SwitchingPoint: ($leftState, $rightState)" } } } } @@ -133,7 +157,7 @@ class DataExtractor(val extractionElement: Element) { private val url: HttpUrl = extractionElement.ownerDocument()?.location()?.toHttpUrlOrNull() ?: reportErrorToUser { buildString { - append("DataExtractor class requires a \"from\" element ") + append("DataExtractor class requires an \"extractionElement\" element ") append("that possesses an owner document with a valid absolute location(), but ") append(extractionElement.ownerDocument()?.location()) append(" was found!") @@ -151,7 +175,7 @@ class DataExtractor(val extractionElement: Element) { * JSoup's [Element.attr] methods supports the special `abs:` syntax when working with relative URLs. * It is simply a shortcut to [Element.absUrl], which uses [Document.baseUri]. */ - val allHrefAnchors: Map by lazy { + val allHrefAnchors: Map by unexpectedErrorCatchingLazy { buildMap { extractionElement.select("a[href]").forEach { a -> val href = a.attr("abs:href") @@ -168,7 +192,7 @@ class DataExtractor(val extractionElement: Element) { * * Meaning this property contains only elements that redirect to a Project Suki URL. */ - val psHrefAnchors: Map by lazy { + val psHrefAnchors: Map by unexpectedErrorCatchingLazy { allHrefAnchors.filterValues { url -> url.host.endsWith(homepageUrl.host) } @@ -195,7 +219,7 @@ class DataExtractor(val extractionElement: Element) { * This has the disadvantage of making distinguishing between the different elements in a single page a nightmare, * but luckly we don't need to do that for the purposes of a Tachiyomi extension. */ - val books: Set by lazy { + val books: Set by unexpectedErrorCatchingLazy { buildSet { data class BookUrlContainerElement(val container: Element, val href: HttpUrl, val matchResult: PathMatchResult) @@ -220,7 +244,7 @@ class DataExtractor(val extractionElement: Element) { .filter { it.parents().none { p -> p.tag().normalName() == "small" } } .map { it.ownText() } .filter { !it.equals("show more", ignoreCase = true) } - .firstOrNull() ?: reportErrorToUser { "Could not determine title for $bookID" } + .firstOrNull() ?: reportErrorToUser("DataExtractor.books") { "Could not determine title for $bookID" } add( PSBook( @@ -237,10 +261,10 @@ class DataExtractor(val extractionElement: Element) { } } - /** Utility class that extends [PSBook], by providing a [detailsTable], [alertData] and [description]. */ + /** Utility class that extends [PSBook], by providing a [details], [alertData] and [description]. */ data class PSBookDetails( val book: PSBook, - val detailsTable: EnumMap, + val details: Map, val alertData: List, val description: String, ) { @@ -254,37 +278,122 @@ class DataExtractor(val extractionElement: Element) { * The process for extracting the details is described in the KDoc for [bookDetails]. */ @Suppress("RegExpUnnecessaryNonCapturingGroup") - enum class BookDetail(val display: String, val regex: Regex, val elementProcessor: (Element) -> String = { it.text() }) { - ALT_TITLE("Alt titles:", """(?:alternative|alt\.?) titles?:?""".toRegex(RegexOption.IGNORE_CASE)), - AUTHOR("Authors:", """authors?:?""".toRegex(RegexOption.IGNORE_CASE)), - ARTIST("Artists:", """artists?:?""".toRegex(RegexOption.IGNORE_CASE)), - STATUS("Status:", """status:?""".toRegex(RegexOption.IGNORE_CASE)), - ORIGIN("Origin:", """origin:?""".toRegex(RegexOption.IGNORE_CASE)), - RELEASE_YEAR("Release year:", """release(?: year):?""".toRegex(RegexOption.IGNORE_CASE)), - USER_RATING( - "User rating:", - """user ratings?:?""".toRegex(RegexOption.IGNORE_CASE), - elementProcessor = { ratings -> + sealed class BookDetail { + + open fun tryFind(extractor: DataExtractor): Collection = emptyList() + + abstract val regex: Regex + fun process(element: Element): ProcessedData = ProcessedData(label(element), detailsData(element)) + abstract fun label(element: Element?): String + abstract fun detailsData(element: Element): String + + data class ProcessedData(val label: String, val detailData: String) + + object AltTitle : BookDetail() { + override val regex: Regex = """(?:alternative|alt\.?) titles?:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Alt titles:" + override fun detailsData(element: Element): String = element.text() + } + + object Author : BookDetail() { + override fun tryFind(extractor: DataExtractor): Collection = extractor.psHrefAnchors.filter { (_, url) -> + url.queryParameterNames.contains("author") + }.keys + + override val regex: Regex = """authors?:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Authors:" + override fun detailsData(element: Element): String = element.text() + } + + object Artist : BookDetail() { + override fun tryFind(extractor: DataExtractor): Collection = extractor.psHrefAnchors.filter { (_, url) -> + url.queryParameterNames.contains("artist") + }.keys + + override val regex: Regex = """artists?:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Artists:" + override fun detailsData(element: Element): String = element.text() + } + + object Status : BookDetail() { + override fun tryFind(extractor: DataExtractor): Collection = extractor.psHrefAnchors.filter { (_, url) -> + url.queryParameterNames.contains("status") + }.keys + + override val regex: Regex = """status:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Status:" + override fun detailsData(element: Element): String = element.text() + } + + object Origin : BookDetail() { + override fun tryFind(extractor: DataExtractor): Collection = extractor.psHrefAnchors.filter { (_, url) -> + url.queryParameterNames.contains("origin") + }.keys + + override val regex: Regex = """origin:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Origin:" + override fun detailsData(element: Element): String = element.text() + + internal val koreaRegex: Regex = """kr|korea\s*(?:\(south\))?""".toRegex(RegexOption.IGNORE_CASE) + internal val chinaRegex: Regex = """kr|korea\s*(?:\(south\))?""".toRegex(RegexOption.IGNORE_CASE) + internal val japanRegex: Regex = """kr|korea\s*(?:\(south\))?""".toRegex(RegexOption.IGNORE_CASE) + } + + object ReleaseYear : BookDetail() { + override val regex: Regex = """release(?: year):?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Release year:" + override fun detailsData(element: Element): String = element.text() + } + + object UserRating : BookDetail() { + override fun tryFind(extractor: DataExtractor): Collection = extractor.extractionElement.select("#ratings") + + override val regex: Regex = """user ratings?:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "User rating:" + override fun detailsData(element: Element): String { val rates = when { - ratings.id() != "ratings" -> 0 - else -> ratings.children().count { it.hasClass("text-warning") } + element.id() != "ratings" -> 0 + else -> element.children().count { it.hasClass("text-warning") } } - when (rates) { + return when (rates) { in 1..5 -> "$rates/5" else -> "?/5" } - }, - ), - VIEWS("Views:", """views?:?""".toRegex(RegexOption.IGNORE_CASE)), - OFFICIAL("Official:", """official:?""".toRegex(RegexOption.IGNORE_CASE)), - PURCHASE("Purchase:", """purchase:?""".toRegex(RegexOption.IGNORE_CASE)), - GENRE("Genres:", """genre(?:\(s\))?:?""".toRegex(RegexOption.IGNORE_CASE)), - ; + } + } + + object Views : BookDetail() { + override val regex: Regex = """views?:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Views:" + override fun detailsData(element: Element): String = element.text() + } + + object Official : BookDetail() { + override val regex: Regex = """official:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Official:" + override fun detailsData(element: Element): String = element.text() + } + + object Purchase : BookDetail() { + override val regex: Regex = """purchase:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Purchase:" + override fun detailsData(element: Element): String = element.text() + } + + object Genre : BookDetail() { + override fun tryFind(extractor: DataExtractor): Collection = extractor.psHrefAnchors.filter { (_, url) -> + url.matchAgainst(genreSearchUrlPattern).doesMatch + }.keys + + override val regex: Regex = """genre(?:\(s\))?:?""".toRegex(RegexOption.IGNORE_CASE) + override fun label(element: Element?) = "Genres:" + override fun detailsData(element: Element): String = element.text() + } companion object { - private val values = values().toList() - fun from(type: String): BookDetail? = values.firstOrNull { it.regex.matches(type) } + val all: List = listOf(AltTitle, Author, Artist, Status, Origin, ReleaseYear, UserRating, Views, Official, Purchase, Genre) + fun from(type: String): BookDetail? = all.firstOrNull { it.regex.matches(type) } } } @@ -296,15 +405,15 @@ class DataExtractor(val extractionElement: Element) { * found in the book main page, using generalized heuristics: * * First the algorithm looks for known entries in the "table" by looking for - * the [Status][BookDetail.STATUS] and [Origin][BookDetail.ORIGIN] fields. + * the [Status][BookDetail.Status] and [Origin][BookDetail.Origin] fields. * This is possible because these elements redirect to the [search](https://projectsuki.com/search) * page with "status" and "origin" queries. * - * The [commonParent] between the two elements is found and the table is subsequently analyzed. - * If this method fails, at least the [Author][BookDetail.AUTHOR], [Artist][BookDetail.ARTIST] and [Genre][BookDetail.GENRE] + * The [nearestCommonParent] between the two elements is found and the table is subsequently analyzed. + * If this method fails, at least the [Author][BookDetail.Author], [Artist][BookDetail.Artist] and [Genre][BookDetail.Genre] * details are found via URLs. * - * An extra [Genre][BookDetail.GENRE] is added when possible: + * An extra [Genre][BookDetail.Genre] is added when possible: * - Origin: "kr" -> Genre: "Manhwa" * - Origin: "cn" -> Genre: "Manhua" * - Origin: "jp" -> Genre: "Manga" @@ -313,36 +422,24 @@ class DataExtractor(val extractionElement: Element) { * * The description is expanded with all this information too. */ - val bookDetails: PSBookDetails by lazy { + val bookDetails: PSBookDetails by unexpectedErrorCatchingLazy { val match = url.matchAgainst(bookUrlPattern) if (!match.doesMatch) reportErrorToUser { "cannot extract book details: $url" } val bookID = match["bookid"]!!.value - val authors: Map = psHrefAnchors.filter { (_, url) -> - url.queryParameterNames.contains("author") + fun tryFindDetailsTable(): Element? { + val found: Map> = BookDetail.all + .associateWith { it.tryFind(extractor = this) } + .filterValues { it.isNotEmpty() } + + return nearestCommonParent(found.values.flatMapTo(LinkedHashSet()) { it }) } - val artists: Map = psHrefAnchors.filter { (_, url) -> - url.queryParameterNames.contains("artist") - } + val detailsTable: Element? = tryFindDetailsTable() + val rows: List = detailsTable?.children()?.toList() ?: emptyList() + val details: MutableMap = LinkedHashMap() - val status: Map.Entry = psHrefAnchors.entries.single { (_, url) -> - url.queryParameterNames.contains("status") - } - - val origin: Map.Entry = psHrefAnchors.entries.single { (_, url) -> - url.queryParameterNames.contains("origin") - } - - val genres: Map = psHrefAnchors.filter { (_, url) -> - url.matchAgainst(genreSearchUrlPattern).doesMatch - } - - val details = EnumMap(BookDetail::class.java) - val tableParent: Element? = commonParent(status.key, origin.key) - val rows: List? = tableParent?.children()?.toList() - - for (row in (rows ?: emptyList())) { + for (row in rows) { val cols = row.children() val typeElement = cols.getOrNull(0) ?: continue val valueElement = cols.getOrNull(1) ?: continue @@ -350,30 +447,37 @@ class DataExtractor(val extractionElement: Element) { val typeText = typeElement.text() val detail = BookDetail.from(typeText) ?: continue - details[detail] = detail.elementProcessor(valueElement) + details[detail] = detail.process(valueElement) } - details.getOrPut(BookDetail.AUTHOR) { authors.keys.joinToString(", ") { it.text() } } - details.getOrPut(BookDetail.ARTIST) { artists.keys.joinToString(", ") { it.text() } } - details.getOrPut(BookDetail.STATUS) { status.key.text() } - details.getOrPut(BookDetail.ORIGIN) { origin.key.text() } + run { + val originGenre: String? = details[BookDetail.Origin]?.detailData?.let { originData -> + when { + originData.matches(BookDetail.Origin.koreaRegex) -> "Manhwa" + originData.matches(BookDetail.Origin.chinaRegex) -> "Manhua" + originData.matches(BookDetail.Origin.japanRegex) -> "Manga" + else -> null + } + } - details.getOrPut(BookDetail.GENRE) { genres.keys.joinToString(", ") { it.text() } } - - when (origin.value.queryParameter("origin")) { - "kr" -> "Manhwa" - "cn" -> "Manhua" - "jp" -> "Manga" - else -> null - }?.let { originGenre -> - details[BookDetail.GENRE] = """${details[BookDetail.GENRE]}, $originGenre""" + if (originGenre != null) { + details[BookDetail.Genre] = when (details.containsKey(BookDetail.Genre)) { + true -> { + val (label, data) = details[BookDetail.Genre]!! + BookDetail.ProcessedData(label, if (data.isBlank()) originGenre else """$data, $originGenre""") + } + false -> { + BookDetail.ProcessedData(BookDetail.Genre.label(null), originGenre) + } + } + } } val title: Element? = extractionElement.selectFirst("h2[itemprop=title]") ?: extractionElement.selectFirst("h2") ?: run { // the common table is inside of a "row" wrapper that is the neighbour of the h2 containing the title // if we sort of generalize this, the title should be the first // text-node-bearing child of the table's grandparent - tableParent?.parent()?.parent()?.children()?.firstOrNull { it.textNodes().isNotEmpty() } + detailsTable?.parent()?.parent()?.children()?.firstOrNull { it.textNodes().isNotEmpty() } } val alerts: List = extractionElement.select(".alert, .alert-info") @@ -415,11 +519,11 @@ class DataExtractor(val extractionElement: Element) { PSBookDetails( book = PSBook( bookThumbnailUrl(bookID, extension), - title?.text() ?: reportErrorToUser { "could not determine book title from details for $bookID" }, + title?.text() ?: reportErrorToUser("DataExtractor.bookDetails") { "could not determine title for $bookID" }, url, bookID, ), - detailsTable = details, + details = details, alertData = alerts, description = description, ) @@ -463,8 +567,8 @@ class DataExtractor(val extractionElement: Element) { } companion object { - val all: Set by lazy { setOf(Chapter, Group, Added, Language, Views) } - val required: Set by lazy { all.filterTo(LinkedHashSet()) { it.required } } + val all: Set by unexpectedErrorCatchingLazy { setOf(Chapter, Group, Added, Language, Views) } + val required: Set by unexpectedErrorCatchingLazy { all.filterTo(LinkedHashSet()) { it.required } } /** * Takes the list of [headers] and returns a map that @@ -517,7 +621,7 @@ class DataExtractor(val extractionElement: Element) { * Then the `` rows (``) are one by one processed to find the ones that match the column (``) * size and data type positions that we care about. */ - val bookChapters: Map> by lazy { + val bookChapters: Map> by unexpectedErrorCatchingLazy { data class RawTable(val self: Element, val thead: Element, val tbody: Element) data class AnalyzedTable(val raw: RawTable, val columnDataTypes: Map, val dataRows: List) @@ -617,7 +721,7 @@ class DataExtractor(val extractionElement: Element) { override fun compareTo(other: ChapterNumber): Int = comparator.compare(this, other) companion object { - val comparator: Comparator by lazy { compareBy({ it.main }, { it.sub }) } + val comparator: Comparator by unexpectedErrorCatchingLazy { compareBy({ it.main }, { it.sub }) } val chapterNumberRegex: Regex = """(?:chapter|ch\.?)\s*(\d+)(?:\s*[.,-]\s*(\d+)?)?""".toRegex(RegexOption.IGNORE_CASE) } } diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PathPattern.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PathPattern.kt index 734b821c7..9c43e17fb 100644 --- a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PathPattern.kt +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/PathPattern.kt @@ -22,9 +22,7 @@ data class PathPattern(val paths: List) { init { if (paths.isEmpty()) { - reportErrorToUser { - "Invalid PathPattern, cannot be empty!" - } + reportErrorToUser { "Invalid PathPattern, cannot be empty!" } } } } @@ -51,9 +49,7 @@ data class PathMatchResult(val doesMatch: Boolean, val matchResults: List String): Nothing { - error("""$reportPrefix: ${message()}""") +/** + * Simple named exception to differentiate it with all other "unexpected" exceptions. + * @see unexpectedErrorCatchingLazy + */ +internal class ProjectSukiException(message: String, cause: Throwable? = null) : Exception(message, cause) + +/** Throws a [ProjectSukiException], which will get caught by Tachiyomi: the message will be exposed as a [toast][android.widget.Toast]. */ +internal inline fun reportErrorToUser(locationHint: String? = null, message: () -> String): Nothing { + throw ProjectSukiException( + buildString { + append("[") + append(reportPrefix) + append("""]: """) + append(message()) + if (!locationHint.isNullOrBlank()) { + append(" @$locationHint") + } + }, + ) } /** Used when chapters don't have a [Language][DataExtractor.ChaptersTableColumnDataType.Language] column (if that ever happens). */ @@ -336,31 +357,94 @@ class ProjectSuki : HttpSource(), ConfigurableSource { ?.state ?.let { ProjectSukiFilters.SearchMode[it] } ?: ProjectSukiFilters.SearchMode.SMART + fun BookID.toMangasPageObservable(): Observable { + val rawSManga = SManga.create().apply { + url = bookIDToURL().rawRelative ?: reportErrorToUser { "Could not create relative url for bookID: $this" } + } + + return client.newCall(mangaDetailsRequest(rawSManga)) + .asObservableSuccess() + .map { response -> mangaDetailsParse(response) } + .map { manga -> MangasPage(listOf(manga), hasNextPage = false) } + } + + val queryAsURL: HttpUrl? by unexpectedErrorCatchingLazy { query.toHttpUrlOrNull() ?: """${homepageUri}$query""".toHttpUrlOrNull() } + val bookUrlMatch by unexpectedErrorCatchingLazy { queryAsURL?.matchAgainst(bookUrlPattern) } + val readUrlMatch by unexpectedErrorCatchingLazy { queryAsURL?.matchAgainst(chapterUrlPattern) } + return when { - // sent by the url activity, might also be because the user entered a query via $ps: + // sent by the url activity, might also be because the user entered a query via $ps-search: // but that won't really happen unless the user wants to do that - query.startsWith(INTENT_QUERY_PREFIX) -> { - val urlQuery = query.removePrefix(INTENT_QUERY_PREFIX) + query.startsWith(INTENT_SEARCH_QUERY_PREFIX) -> { + val urlQuery = query.removePrefix(INTENT_SEARCH_QUERY_PREFIX) if (urlQuery.isBlank()) error("Empty search query!") val rawUrl = """${homepageUri.toASCIIString()}/search?$urlQuery""" - val url = rawUrl.toHttpUrlOrNull() ?: reportErrorToUser { - "Invalid search url: $rawUrl" - } + val url = rawUrl.toHttpUrlOrNull() ?: reportErrorToUser { "Invalid search url: $rawUrl" } client.newCall(GET(url, headers)) .asObservableSuccess() - .map { response -> searchMangaParse(response, false) } + .map { response -> searchMangaParse(response, overrideHasNextPage = false) } + } + + // sent by the book activity + query.startsWith(INTENT_BOOK_QUERY_PREFIX) -> { + val bookid = query.removePrefix(INTENT_BOOK_QUERY_PREFIX) + if (bookid.isBlank()) error("Empty bookid!") + + bookid.toMangasPageObservable() + } + + // sent by the read activity + query.startsWith(INTENT_READ_QUERY_PREFIX) -> { + val bookid = query.removePrefix(INTENT_READ_QUERY_PREFIX) + if (bookid.isBlank()) error("Empty bookid!") + + bookid.toMangasPageObservable() + } + + bookUrlMatch?.doesMatch == true -> { + val bookid = bookUrlMatch!!["bookid"]!!.value + if (bookid.isBlank()) error("Empty bookid!") + + bookid.toMangasPageObservable() + } + + readUrlMatch?.doesMatch == true -> { + val bookid = readUrlMatch!!["bookid"]!!.value + if (bookid.isBlank()) error("Empty bookid!") + + bookid.toMangasPageObservable() } // use result from https://projectsuki.com/api/book/search - searchMode == ProjectSukiFilters.SearchMode.SMART || searchMode == ProjectSukiFilters.SearchMode.SIMPLE -> { - val simpleMode = searchMode == ProjectSukiFilters.SearchMode.SIMPLE + searchMode == ProjectSukiFilters.SearchMode.SMART -> { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + error( + buildString { + append("Please enable ") + append(ProjectSukiFilters.SearchMode.SIMPLE) + append(" Search Mode: ") + append(ProjectSukiFilters.SearchMode.SMART) + append(" mode requires Android API version >= 24, but ") + append(Build.VERSION.SDK_INT) + append(" was found!") + }, + ) + } client.newCall(ProjectSukiAPI.bookSearchRequest(json, headers)) .asObservableSuccess() .map { response -> ProjectSukiAPI.parseBookSearchResponse(json, response) } - .map { data -> data.toMangasPage(query, simpleMode) } + .map { data -> SmartBookSearchHandler(query, data).mangasPage } + } + + // use result from https://projectsuki.com/api/book/search + searchMode == ProjectSukiFilters.SearchMode.SIMPLE -> { + client.newCall(ProjectSukiAPI.bookSearchRequest(json, headers)) + .asObservableSuccess() + .map { response -> ProjectSukiAPI.parseBookSearchResponse(json, response) } + .map { data -> data.simpleSearchMangasPage(query) } } // use https://projectsuki.com/search @@ -449,11 +533,11 @@ class ProjectSuki : HttpSource(), ConfigurableSource { title = data.book.rawTitle thumbnail_url = data.book.thumbnail.toUri().toASCIIString() - author = data.detailsTable[DataExtractor.BookDetail.AUTHOR] - artist = data.detailsTable[DataExtractor.BookDetail.ARTIST] - status = when (data.detailsTable[DataExtractor.BookDetail.STATUS]?.trim()?.lowercase(Locale.US)) { + author = data.details[DataExtractor.BookDetail.Author]?.detailData + artist = data.details[DataExtractor.BookDetail.Artist]?.detailData + status = when (data.details[DataExtractor.BookDetail.Status]?.detailData?.trim()?.lowercase(Locale.US)) { "ongoing" -> SManga.ONGOING - "completed" -> SManga.PUBLISHING_FINISHED + "completed" -> SManga.COMPLETED "hiatus" -> SManga.ON_HIATUS "cancelled" -> SManga.CANCELLED else -> SManga.UNKNOWN @@ -461,7 +545,7 @@ class ProjectSuki : HttpSource(), ConfigurableSource { description = buildString { if (data.alertData.isNotEmpty()) { - appendLine("Alerts have been found, refreshing the manga later might help in removing them.") + appendLine("Alerts have been found, refreshing the book/manga later might help in removing them.") appendLine() data.alertData.forEach { @@ -469,14 +553,20 @@ class ProjectSuki : HttpSource(), ConfigurableSource { appendLine() } + appendLine(DESCRIPTION_DIVIDER) + appendLine() + appendLine() } appendLine(data.description) appendLine() - data.detailsTable.forEach { (detail, value) -> - append(detail.display) + appendLine(DESCRIPTION_DIVIDER) + appendLine() + + data.details.values.forEach { (label, value) -> + append(label) append(" ") append(value.trim()) @@ -485,11 +575,11 @@ class ProjectSuki : HttpSource(), ConfigurableSource { } update_strategy = when (status) { - SManga.CANCELLED, SManga.PUBLISHING_FINISHED -> UpdateStrategy.ONLY_FETCH_ONCE + SManga.CANCELLED, SManga.COMPLETED, SManga.PUBLISHING_FINISHED -> UpdateStrategy.ONLY_FETCH_ONCE else -> UpdateStrategy.ALWAYS_UPDATE } - genre = data.detailsTable[DataExtractor.BookDetail.GENRE]!! + genre = data.details[DataExtractor.BookDetail.Genre]!!.detailData } } @@ -563,9 +653,7 @@ class ProjectSuki : HttpSource(), ConfigurableSource { override fun fetchPageList(chapter: SChapter): Observable> { val pathMatch: PathMatchResult = """${homepageUri.toASCIIString()}/${chapter.url}""".toHttpUrl().matchAgainst(chapterUrlPattern) if (!pathMatch.doesMatch) { - reportErrorToUser { - "chapter url ${chapter.url} does not match expected pattern" - } + reportErrorToUser { "chapter url ${chapter.url} does not match expected pattern" } } return client.newCall(ProjectSukiAPI.chapterPagesRequest(json, headers, pathMatch["bookid"]!!.value, pathMatch["chapterid"]!!.value)) @@ -584,8 +672,12 @@ class ProjectSuki : HttpSource(), ConfigurableSource { /** * Not used in this extension, as we override [fetchPageList] to modify the default behaviour. */ - override fun pageListParse(response: Response): List = reportErrorToUser { + override fun pageListParse(response: Response): List = reportErrorToUser("ProjectSuki.pageListParse") { // give a hint on who called this method - "invalid ${Thread.currentThread().stackTrace.take(3)}" + "invalid ${Thread.currentThread().stackTrace.asSequence().drop(1).take(3).toList()}" + } + + companion object { + private const val DESCRIPTION_DIVIDER: String = "/=/-/=/-/=/-/=/-/=/-/=/-/=/-/=/" } } diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiAPI.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiAPI.kt index 048389a87..6bc907c4a 100644 --- a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiAPI.kt +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiAPI.kt @@ -1,14 +1,8 @@ package eu.kanade.tachiyomi.extension.all.projectsuki -import android.icu.text.BreakIterator -import android.icu.text.Collator -import android.icu.text.RuleBasedCollator -import android.icu.text.StringSearch -import android.os.Build import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString @@ -26,7 +20,6 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import org.jsoup.Jsoup import org.jsoup.nodes.Element -import java.text.StringCharacterIterator /** * @see EXTENSION_INFO Found in ProjectSuki.kt @@ -95,9 +88,7 @@ object ProjectSukiAPI { ) { init { if (first != "true" && first != "false") { - reportErrorToUser { - "PagesRequestData, first was \"$first\"" - } + reportErrorToUser { "PagesRequestData, first was \"$first\"" } } } } @@ -136,9 +127,7 @@ object ProjectSukiAPI { ?.tryAs() ?.get("src") ?.tryAs() - ?.content ?: reportErrorToUser { - "chapter pages aren't in the expected format!" - } + ?.content ?: reportErrorToUser { "chapter pages aren't in the expected format!" } // we can handle relative urls by specifying manually the location of the "document" val srcFragment: Element = Jsoup.parseBodyFragment(rawSrc, homepageUri.toASCIIString()) @@ -150,9 +139,7 @@ object ProjectSukiAPI { .filterValues { it.doesMatch } // make sure they are the urls we expect if (urls.isEmpty()) { - reportErrorToUser { - "chapter pages URLs aren't in the expected format!" - } + reportErrorToUser { "chapter pages URLs aren't in the expected format!" } } return urls.entries @@ -197,18 +184,14 @@ object ProjectSukiAPI { .getOrNull() ?.tryAs() ?.get("data") - ?.tryAs() ?: reportErrorToUser { - "books data isn't in the expected format!" - } + ?.tryAs() ?: reportErrorToUser { "books data isn't in the expected format!" } val refined: Map = buildMap { data.forEach { (id: BookID, valueObj: JsonElement) -> val title: BookTitle = valueObj.tryAs() ?.get("value") ?.tryAs() - ?.content ?: reportErrorToUser { - "books data isn't in the expected format!" - } + ?.content ?: reportErrorToUser { "books data isn't in the expected format!" } this[id] = title } @@ -226,128 +209,32 @@ private val alphaNumericRegex = """\p{Alnum}+""".toRegex(RegexOption.IGNORE_CASE * If Even a single "word" from [searchQuery] matches, then the manga will be included, * but sorting is done based on the amount of matches. */ -internal fun Map.toMangasPage(searchQuery: String, useSimpleMode: Boolean): MangasPage { - data class Match(val bookID: BookID, val title: BookTitle, val count: Int) { - val bookUrl: HttpUrl = homepageUrl.newBuilder() - .addPathSegment("book") - .addPathSegment(bookID) - .build() - } +internal fun Map.simpleSearchMangasPage(searchQuery: String): MangasPage { + data class Match(val bookID: BookID, val title: BookTitle, val count: Int) - when { - useSimpleMode -> { - // simple search, possibly faster - val words: Set = alphaNumericRegex.findAll(searchQuery).mapTo(HashSet()) { it.value } + val words: Set = alphaNumericRegex.findAll(searchQuery).mapTo(HashSet()) { it.value } - val matches: Map = mapValues { (bookID, bookTitle) -> - val matchesCount: Int = words.sumOf { word -> - var count = 0 - var idx = 0 + val matches: Map = mapValues { (bookID, bookTitle) -> + val matchesCount: Int = words.sumOf { word -> + var count = 0 + var idx = 0 - while (true) { - val found = bookTitle.indexOf(word, idx, ignoreCase = true) - if (found < 0) break + while (true) { + val found = bookTitle.indexOf(word, idx, ignoreCase = true) + if (found < 0) break - idx = found + 1 - count++ - } - - count - } - - Match(bookID, bookTitle, matchesCount) - }.filterValues { it.count > 0 } - - return MangasPage( - mangas = matches.entries - .sortedWith(compareBy({ -it.value.count }, { it.value.title })) - .map { (bookID, match: Match) -> - SManga.create().apply { - title = match.title - url = match.bookUrl.rawRelative ?: reportErrorToUser { "Could not relativize ${match.bookUrl}" } - thumbnail_url = bookThumbnailUrl(bookID, "").toUri().toASCIIString() - } - }, - hasNextPage = false, - ) - } - - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N -> { - // use ICU, better - - val searchWords: Set = BreakIterator.getWordInstance().run { - text = StringCharacterIterator(searchQuery) - - var left = first() - var right = next() - - buildSet { - while (right != BreakIterator.DONE) { - if (ruleStatus != BreakIterator.WORD_NONE) { - add(searchQuery.substring(left, right)) - } - left = right - right = next() - } - } + idx = found + 1 + count++ } - val stringSearch = StringSearch( - /* pattern = */ "dummy", - /* target = */ StringCharacterIterator("dummy"), - /* collator = */ - (Collator.getInstance() as RuleBasedCollator).apply { - isCaseLevel = true - strength = Collator.PRIMARY - decomposition = Collator.CANONICAL_DECOMPOSITION - }, - ) - - val matches: Map = mapValues { (bookID, bookTitle) -> - stringSearch.target = StringCharacterIterator(bookTitle) - - val matchesCount: Int = searchWords.sumOf { word -> - val search: StringSearch = stringSearch.apply { - this.pattern = word - } - - var count = 0 - var idx = search.first() - while (idx != StringSearch.DONE) { - count++ - idx = search.next() - } - - count - } - - Match(bookID, bookTitle, matchesCount) - }.filterValues { it.count > 0 } - - return MangasPage( - mangas = matches.entries - .sortedWith(compareBy({ -it.value.count }, { it.value.title })) - .map { (bookID, match: Match) -> - SManga.create().apply { - title = match.title - url = match.bookUrl.rawRelative ?: reportErrorToUser { "Could not relativize ${match.bookUrl}" } - thumbnail_url = bookThumbnailUrl(bookID, "").toUri().toASCIIString() - } - }, - hasNextPage = false, - ) + count } - else -> error( - buildString { - append("Please enable ") - append(ProjectSukiFilters.SearchMode.SIMPLE) - append(" Search Mode: ") - append(ProjectSukiFilters.SearchMode.SMART) - append(" search requires Android API version >= 24, but ") - append(Build.VERSION.SDK_INT) - append(" was found!") - }, - ) - } + Match(bookID, bookTitle, matchesCount) + }.filterValues { it.count > 0 } + + return matches.entries + .sortedWith(compareBy({ -it.value.count }, { it.value.title })) + .associate { (bookID, match) -> bookID to match.title } + .toMangasPage() } diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/SmartBookSearchHandler.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/SmartBookSearchHandler.kt new file mode 100644 index 000000000..c28f6b003 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/SmartBookSearchHandler.kt @@ -0,0 +1,223 @@ +package eu.kanade.tachiyomi.extension.all.projectsuki + +import android.icu.text.BreakIterator +import android.icu.text.Collator +import android.icu.text.Normalizer2 +import android.icu.text.RuleBasedCollator +import android.icu.text.StringSearch +import android.os.Build +import androidx.annotation.RequiresApi +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import okhttp3.HttpUrl +import java.text.StringCharacterIterator +import java.util.TreeMap +import kotlin.properties.PropertyDelegateProvider +import kotlin.reflect.KProperty + +/** + * @see EXTENSION_INFO Found in ProjectSuki.kt + */ +@Suppress("unused") +private inline val INFO: Nothing get() = error("INFO") + +private typealias IsWord = Boolean + +/** + * Handles the "Smart" book search mode, requires [API][RequiresApi] [Build.VERSION_CODES.N] + * because it uses [android.icu.text] classes. + * + * Tries to avoid wasting resource by normalizing text only once and using [ThreadLocal] instances. + */ +@Suppress("MemberVisibilityCanBePrivate") +@RequiresApi(Build.VERSION_CODES.N) +class SmartBookSearchHandler(val rawQuery: String, val rawBooksData: Map) { + + data class WordsData(val words: List, val extra: List, val wordRanges: List) + + data class CollatedElement(val value: String, val range: IntRange, val origin: String, val category: Cat) + + @Suppress("NOTHING_TO_INLINE") + private inline fun BreakIterator.collate(text: String): List> = collate(text) {} + private inline fun BreakIterator.collate(text: String, categorizer: (ruleStatus: Int) -> Cat): List> { + this.text = StringCharacterIterator(text) + var left = first() + var right = next() + + return buildList { + while (right != BreakIterator.DONE) { + val cat = categorizer(ruleStatus) + val value = text.substring(left, right) + + add(CollatedElement(value, left..right, text, cat)) + + left = right + right = next() + } + } + } + + private val normQuery by unexpectedErrorCatchingLazy { normalizer.normalize(rawQuery) } + + val wordsData: WordsData by unexpectedErrorCatchingLazy { + val charBreak = charBreak + val wordBreak = wordBreak + + val words: List> = wordBreak.collate(normQuery) { ruleStatus -> + when (ruleStatus) { + BreakIterator.WORD_NONE -> false + else -> true + } + } + val extra: MutableList = ArrayList() + + words.forEach { collatedElement -> + if (!collatedElement.category) { + // not a "word" per-say + extra.addAll(charBreak.collate(collatedElement.value) { Unit }.map { it.value }) + } + } + + WordsData( + words = words.filter { it.category }.map { it.value }, + extra = extra, + wordRanges = words.filter { it.category }.map { it.range }, + ) + } + + val normalizedBooksData: Map by unexpectedErrorCatchingLazy { + val normalizer = normalizer + rawBooksData.mapValues { normalizer.normalize(it.value) } + } + + @OptIn(ExperimentalUnsignedTypes::class) + fun wordScoreFor(matchedText: String): UInt { + // not starting from index 0 is intentional here + return fib32.getOrElse(charBreak.collate(matchedText).size) { fib32.last() } + } + + val filteredBooks: Set by unexpectedErrorCatchingLazy { + val stringSearch = stringSearch + val wordsData = wordsData + val booksData: Map = normalizedBooksData + + class Counter(var value: UInt) + + val scored: Map = booksData.mapValues { Counter(0u) } + + booksData.forEach { (bookID, normTitle) -> + val score = scored[bookID]!! + stringSearch.target = StringCharacterIterator(normTitle) + + wordsData.words.forEach { word -> + stringSearch.pattern = word + var idx = stringSearch.first() + while (idx != StringSearch.DONE) { + score.value += wordScoreFor(stringSearch.matchedText) + idx = stringSearch.next() + } + } + + wordsData.extra.forEach { extra -> + stringSearch.pattern = extra + var idx = stringSearch.first() + while (idx != StringSearch.DONE) { + score.value += SCORE_EXTRA + idx = stringSearch.next() + } + } + } + + val byScore: TreeMap>> = scored.entries.groupByTo(TreeMap(reverseOrder())) { it.value.value } + val highest = byScore.firstKey().toFloat() + + val included: MutableSet = LinkedHashSet() + for ((score, group) in byScore) { + val include = score > 0u && (included.size < MINIMUM_RESULTS || (score.toFloat() / highest) >= NEEDED_FRACTIONAL_SCORE_FOR_INCLUSION) + if (!include) break + + included.addAll(group.map { it.key }) + } + + included + } + + val mangasPage: MangasPage by unexpectedErrorCatchingLazy { + filteredBooks.associateWith { rawBooksData[it]!! } + .toMangasPage() + } + + companion object { + private inline fun threadLocal(crossinline initializer: () -> T) = PropertyDelegateProvider> { _: Any?, _: KProperty<*> -> + object : ThreadLocal() { + override fun initialValue(): T? = initializer() + } + } + + private operator fun ThreadLocal.getValue(thisRef: Any?, property: KProperty<*>): T { + return get() ?: reportErrorToUser("SmartBookSearchHandler.${property.name}") { "null initialValue" } + } + + private val charBreak: BreakIterator by threadLocal { BreakIterator.getCharacterInstance() } + private val wordBreak: BreakIterator by threadLocal { BreakIterator.getWordInstance() } + private val normalizer: Normalizer2 by threadLocal { Normalizer2.getNFKCCasefoldInstance() } + private val collator: RuleBasedCollator by threadLocal { + (Collator.getInstance() as RuleBasedCollator).apply { + isCaseLevel = true + strength = Collator.PRIMARY + decomposition = Collator.NO_DECOMPOSITION // !! must handle this ourselves !! + } + } + + private val stringSearch: StringSearch by threadLocal { + StringSearch( + /* pattern = */ "dummy", + /* target = */ StringCharacterIterator("dummy"), + /* collator = */ collator, + ).apply { + isOverlapping = true + } + } + + /** first 32 fibonacci numbers */ + @JvmStatic + @OptIn(ExperimentalUnsignedTypes::class) + private val fib32: UIntArray = uintArrayOf( + 1u, 1u, 2u, 3u, + 5u, 8u, 13u, 21u, + 34u, 55u, 89u, 144u, + 233u, 377u, 610u, 987u, + 1597u, 2584u, 4181u, 6765u, + 10946u, 17711u, 28657u, 46368u, + 75025u, 121393u, 196418u, 317811u, + 514229u, 832040u, 1346269u, 2178309u, + ) + + private const val SCORE_EXTRA: UInt = 1u + + private const val NEEDED_FRACTIONAL_SCORE_FOR_INCLUSION: Float = 0.5f + private const val MINIMUM_RESULTS: Int = 8 + } +} + +/** simply creates an https://projectsuki.com/book/ [HttpUrl] */ +internal fun BookID.bookIDToURL(): HttpUrl { + return homepageUrl.newBuilder() + .addPathSegment("book") + .addPathSegment(this) + .build() +} + +internal fun Map.toMangasPage(hasNextPage: Boolean = false): MangasPage = entries.toMangasPage(hasNextPage) +internal fun Iterable>.toMangasPage(hasNextPage: Boolean = false): MangasPage { + return MangasPage( + mangas = map { (bookID: BookID, bookTitle: BookTitle) -> + SManga.create().apply { + title = bookTitle + url = bookID.bookIDToURL().rawRelative ?: reportErrorToUser { "Could not create relative url for bookID: $bookID" } + thumbnail_url = bookThumbnailUrl(bookID, "").toUri().toASCIIString() + } + }, + hasNextPage = hasNextPage, + ) +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiBookUrlActivity.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiBookUrlActivity.kt new file mode 100644 index 000000000..816180f31 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiBookUrlActivity.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.extension.all.projectsuki.activities + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import eu.kanade.tachiyomi.extension.all.projectsuki.EXTENSION_INFO +import eu.kanade.tachiyomi.extension.all.projectsuki.SHORT_FORM_ID +import kotlin.system.exitProcess + +/** + * @see EXTENSION_INFO Found in ProjectSuki.kt + */ +@Suppress("unused") +private inline val INFO: Nothing get() = error("INFO") + +internal const val INTENT_BOOK_QUERY_PREFIX: String = """${'$'}$SHORT_FORM_ID-book:""" + +/** + * See [handleIntentAction](https://github.com/tachiyomiorg/tachiyomi/blob/0f9895eec8f5808210f291d1e0ef5cc9f73ccb44/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt#L401) + * and [GlobalSearchScreen](https://github.com/tachiyomiorg/tachiyomi/blob/0f9895eec8f5808210f291d1e0ef5cc9f73ccb44/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt#L19) + * (these are permalinks, search for updated variants). + * + * See [AndroidManifest.xml](https://developer.android.com/guide/topics/manifest/manifest-intro) + * for what URIs this [Activity](https://developer.android.com/guide/components/activities/intro-activities) + * can receive. + * + * For this specific class you can test the activity by doing (see [CONTRIBUTING](https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master/CONTRIBUTING.md#url-intent-filter)): + * ``` + * adb shell am start -d "https://projectsuki.com/book/192047" -a android.intent.action.VIEW + * ``` + * + * If multiple devices are present, [see this answer](https://stackoverflow.com/a/14655015). + * + * Note that Tachiyomi extension's UrlActivities [do not have access to the Kotlin environment](https://github.com/tachiyomiorg/tachiyomi-extensions/pull/19323#issuecomment-1858932113) + * (specifically Intrinsics). + * To avoid using Java, multiple classes have been provided for different URLs. + * + * @author Federico d'Alonzo <me@npgx.dev> + */ +class ProjectSukiBookUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if ((intent?.data?.pathSegments?.size ?: 0) >= 2) { + // Project Suki Url Activity Book + Log.e("PSUABook", "could not handle URI ${intent?.data} from intent $intent") + } + + val intent = Intent().apply { + // tell tachiyomi we want to search for something + action = "eu.kanade.tachiyomi.SEARCH" + // "filter" for our own extension instead of doing a global search + putExtra("filter", packageName) + // value that will be passed onto the "query" parameter in fetchSearchManga + putExtra("query", "$INTENT_BOOK_QUERY_PREFIX${intent?.data?.pathSegments?.get(1)}") + } + + try { + // actually do the thing + startActivity(intent) + } catch (e: ActivityNotFoundException) { + // tachiyomi isn't installed (?) + // Project Suki Url Activity Book + Log.e("PSUABook", e.toString()) + } + + // we're done + finish() + // just for safety + exitProcess(0) + } +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiReadUrlActivity.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiReadUrlActivity.kt new file mode 100644 index 000000000..2f17eee11 --- /dev/null +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiReadUrlActivity.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.extension.all.projectsuki.activities + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import eu.kanade.tachiyomi.extension.all.projectsuki.EXTENSION_INFO +import eu.kanade.tachiyomi.extension.all.projectsuki.SHORT_FORM_ID +import kotlin.system.exitProcess + +/** + * @see EXTENSION_INFO Found in ProjectSuki.kt + */ +@Suppress("unused") +private inline val INFO: Nothing get() = error("INFO") + +internal const val INTENT_READ_QUERY_PREFIX: String = """${'$'}$SHORT_FORM_ID-read:""" + +/** + * See [handleIntentAction](https://github.com/tachiyomiorg/tachiyomi/blob/0f9895eec8f5808210f291d1e0ef5cc9f73ccb44/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt#L401) + * and [GlobalSearchScreen](https://github.com/tachiyomiorg/tachiyomi/blob/0f9895eec8f5808210f291d1e0ef5cc9f73ccb44/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/GlobalSearchScreen.kt#L19) + * (these are permalinks, search for updated variants). + * + * See [AndroidManifest.xml](https://developer.android.com/guide/topics/manifest/manifest-intro) + * for what URIs this [Activity](https://developer.android.com/guide/components/activities/intro-activities) + * can receive. + * + * For this specific class you can test the activity by doing (see [CONTRIBUTING](https://github.com/tachiyomiorg/tachiyomi-extensions/blob/master/CONTRIBUTING.md#url-intent-filter)): + * ``` + * adb shell am start -d "https://projectsuki.com/read/192047/5069/1" -a android.intent.action.VIEW + * ``` + * + * If multiple devices are present, [see this answer](https://stackoverflow.com/a/14655015). + * + * Note that Tachiyomi extension's UrlActivities [do not have access to the Kotlin environment](https://github.com/tachiyomiorg/tachiyomi-extensions/pull/19323#issuecomment-1858932113) + * (specifically Intrinsics). + * To avoid using Java, multiple classes have been provided for different URLs. + * + * @author Federico d'Alonzo <me@npgx.dev> + */ +class ProjectSukiReadUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if ((intent?.data?.pathSegments?.size ?: 0) >= 2) { + // Project Suki Url Activity Read + Log.e("PSUARead", "could not handle URI ${intent?.data} from intent $intent") + } + + val intent = Intent().apply { + // tell tachiyomi we want to search for something + action = "eu.kanade.tachiyomi.SEARCH" + // "filter" for our own extension instead of doing a global search + putExtra("filter", packageName) + // value that will be passed onto the "query" parameter in fetchSearchManga + putExtra("query", "$INTENT_READ_QUERY_PREFIX${intent?.data?.pathSegments?.get(1)}") + } + + try { + // actually do the thing + startActivity(intent) + } catch (e: ActivityNotFoundException) { + // tachiyomi isn't installed (?) + // Project Suki Url Activity Read + Log.e("PSUARead", e.toString()) + } + + // we're done + finish() + // just for safety + exitProcess(0) + } +} diff --git a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiSearchUrlActivity.kt b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiSearchUrlActivity.kt similarity index 69% rename from src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiSearchUrlActivity.kt rename to src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiSearchUrlActivity.kt index 703ef32a2..00e8bc62a 100644 --- a/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/ProjectSukiSearchUrlActivity.kt +++ b/src/all/projectsuki/src/eu/kanade/tachiyomi/extension/all/projectsuki/activities/ProjectSukiSearchUrlActivity.kt @@ -1,10 +1,12 @@ -package eu.kanade.tachiyomi.extension.all.projectsuki +package eu.kanade.tachiyomi.extension.all.projectsuki.activities import android.app.Activity import android.content.ActivityNotFoundException import android.content.Intent import android.os.Bundle import android.util.Log +import eu.kanade.tachiyomi.extension.all.projectsuki.EXTENSION_INFO +import eu.kanade.tachiyomi.extension.all.projectsuki.SHORT_FORM_ID import kotlin.system.exitProcess /** @@ -13,10 +15,7 @@ import kotlin.system.exitProcess @Suppress("unused") private inline val INFO: Nothing get() = error("INFO") -/** - * `$ps:` - */ -internal const val INTENT_QUERY_PREFIX: String = """${'$'}$SHORT_FORM_ID:""" +internal const val INTENT_SEARCH_QUERY_PREFIX: String = """${'$'}$SHORT_FORM_ID-search:""" /** * See [handleIntentAction](https://github.com/tachiyomiorg/tachiyomi/blob/0f9895eec8f5808210f291d1e0ef5cc9f73ccb44/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt#L401) @@ -32,6 +31,12 @@ internal const val INTENT_QUERY_PREFIX: String = """${'$'}$SHORT_FORM_ID:""" * adb shell am start -d "https://projectsuki.com/search?q=omniscient" -a android.intent.action.VIEW * ``` * + * If multiple devices are present, [see this answer](https://stackoverflow.com/a/14655015) + * + * Note that Tachiyomi extension's UrlActivities [do not have access to the Kotlin environment](https://github.com/tachiyomiorg/tachiyomi-extensions/pull/19323#issuecomment-1858932113) + * (specifically Intrinsics). + * To avoid using Java, multiple classes have been provided for different URLs. + * * @author Federico d'Alonzo <me@npgx.dev> */ class ProjectSukiSearchUrlActivity : Activity() { @@ -39,7 +44,8 @@ class ProjectSukiSearchUrlActivity : Activity() { super.onCreate(savedInstanceState) if (intent?.data?.pathSegments?.size != 1) { - Log.e("PSUrlActivity", "could not handle URI ${intent?.data} from intent $intent") + // Project Suki Url Activity Search + Log.e("PSUASearch", "could not handle URI ${intent?.data} from intent $intent") } val intent = Intent().apply { @@ -48,7 +54,7 @@ class ProjectSukiSearchUrlActivity : Activity() { // "filter" for our own extension instead of doing a global search putExtra("filter", packageName) // value that will be passed onto the "query" parameter in fetchSearchManga - putExtra("query", "${INTENT_QUERY_PREFIX}${intent?.data?.query}") + putExtra("query", "$INTENT_SEARCH_QUERY_PREFIX${intent?.data?.query}") } try { @@ -56,7 +62,8 @@ class ProjectSukiSearchUrlActivity : Activity() { startActivity(intent) } catch (e: ActivityNotFoundException) { // tachiyomi isn't installed (?) - Log.e("PSUrlActivity", e.toString()) + // Project Suki Url Activity Search + Log.e("PSUASearch", e.toString()) } // we're done