mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-21 20:47:03 -05:00
Remove getAbsoluteUrl method
This commit is contained in:
parent
9beeca652f
commit
585f7ec17d
6 changed files with 43 additions and 63 deletions
|
@ -14,7 +14,10 @@ import eu.kanade.tachiyomi.data.source.Source
|
||||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.util.UrlUtil
|
import eu.kanade.tachiyomi.util.UrlUtil
|
||||||
import okhttp3.*
|
import okhttp3.Headers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
@ -397,27 +400,6 @@ abstract class OnlineSource(context: Context) : Source {
|
||||||
|
|
||||||
// Utility methods
|
// Utility methods
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an absolute url from a href.
|
|
||||||
*
|
|
||||||
* Ex:
|
|
||||||
* href="http://example.com/foo" url="http://example.com" -> http://example.com/foo
|
|
||||||
* href="/mypath" url="http://example.com/foo" -> http://example.com/mypath
|
|
||||||
* href="bar" url="http://example.com/foo" -> http://example.com/bar
|
|
||||||
* href="?bar" url="http://example.com/foo" -> http://example.com/foo?bar
|
|
||||||
* href="bar" url="http://example.com/foo/" -> http://example.com/foo/bar
|
|
||||||
*
|
|
||||||
* @param href the href attribute from the html.
|
|
||||||
* @param url the requested url.
|
|
||||||
*/
|
|
||||||
fun getAbsoluteUrl(href: String, url: HttpUrl) = when {
|
|
||||||
href.startsWith("http://") || href.startsWith("https://") -> href
|
|
||||||
href.startsWith("/") -> url.newBuilder().encodedPath("/").fragment(null).query(null)
|
|
||||||
.toString() + href.substring(1)
|
|
||||||
href.startsWith("?") -> url.toString().substringBeforeLast('?') + "$href"
|
|
||||||
else -> url.toString().substringBeforeLast('/') + "/$href"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchAllImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
|
fun fetchAllImageUrlsFromPageList(pages: List<Page>) = Observable.from(pages)
|
||||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||||
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
||||||
|
|
|
@ -5,8 +5,8 @@ import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
* @param page the page object to be filled.
|
* @param page the page object to be filled.
|
||||||
*/
|
*/
|
||||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
for (element in document.select(popularMangaSelector())) {
|
for (element in document.select(popularMangaSelector())) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
popularMangaFromElement(element, this)
|
popularMangaFromElement(element, this)
|
||||||
|
@ -33,9 +33,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
popularMangaNextPageSelector()?.let { selector ->
|
popularMangaNextPageSelector()?.let { selector ->
|
||||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||||
getAbsoluteUrl(it, response.request().url())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,7 +65,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
*/
|
*/
|
||||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
for (element in document.select(searchMangaSelector())) {
|
for (element in document.select(searchMangaSelector())) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
searchMangaFromElement(element, this)
|
searchMangaFromElement(element, this)
|
||||||
|
@ -76,9 +74,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
searchMangaNextPageSelector()?.let { selector ->
|
searchMangaNextPageSelector()?.let { selector ->
|
||||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||||
getAbsoluteUrl(it, response.request().url())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,7 +105,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
* @param manga the manga to fill.
|
* @param manga the manga to fill.
|
||||||
*/
|
*/
|
||||||
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
||||||
mangaDetailsParse(Jsoup.parse(response.body().string()), manga)
|
mangaDetailsParse(response.asJsoup(), manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -127,7 +123,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
* @param chapters the list of chapters to fill.
|
* @param chapters the list of chapters to fill.
|
||||||
*/
|
*/
|
||||||
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
|
|
||||||
for (element in document.select(chapterListSelector())) {
|
for (element in document.select(chapterListSelector())) {
|
||||||
Chapter.create().apply {
|
Chapter.create().apply {
|
||||||
|
@ -157,7 +153,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
* @param pages the list of pages to fill.
|
* @param pages the list of pages to fill.
|
||||||
*/
|
*/
|
||||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||||
pageListParse(Jsoup.parse(response.body().string()), pages)
|
pageListParse(response.asJsoup(), pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -174,7 +170,7 @@ abstract class ParsedOnlineSource(context: Context) : OnlineSource(context) {
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun imageUrlParse(response: Response): String {
|
override fun imageUrlParse(response: Response): String {
|
||||||
return imageUrlParse(Jsoup.parse(response.body().string()))
|
return imageUrlParse(response.asJsoup())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -8,9 +8,9 @@ import eu.kanade.tachiyomi.data.network.POST
|
||||||
import eu.kanade.tachiyomi.data.source.getLanguages
|
import eu.kanade.tachiyomi.data.source.getLanguages
|
||||||
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -52,7 +52,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
override fun popularMangaInitialUrl() = map.popular.url
|
override fun popularMangaInitialUrl() = map.popular.url
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
for (element in document.select(map.popular.manga_css)) {
|
for (element in document.select(map.popular.manga_css)) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
title = element.text()
|
title = element.text()
|
||||||
|
@ -62,9 +62,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
}
|
}
|
||||||
|
|
||||||
map.popular.next_url_css?.let { selector ->
|
map.popular.next_url_css?.let { selector ->
|
||||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||||
getAbsoluteUrl(it, response.request().url())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +79,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
override fun searchMangaInitialUrl(query: String) = map.search.url.replace("\$query", query)
|
override fun searchMangaInitialUrl(query: String) = map.search.url.replace("\$query", query)
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
for (element in document.select(map.search.manga_css)) {
|
for (element in document.select(map.search.manga_css)) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
title = element.text()
|
title = element.text()
|
||||||
|
@ -91,14 +89,12 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
}
|
}
|
||||||
|
|
||||||
map.search.next_url_css?.let { selector ->
|
map.search.next_url_css?.let { selector ->
|
||||||
page.nextPageUrl = document.select(selector).first()?.attr("href")?.let {
|
page.nextPageUrl = document.select(selector).first()?.absUrl("href")
|
||||||
getAbsoluteUrl(it, response.request().url())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
override fun mangaDetailsParse(response: Response, manga: Manga) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
with(map.manga) {
|
with(map.manga) {
|
||||||
val pool = parts.get(document)
|
val pool = parts.get(document)
|
||||||
|
|
||||||
|
@ -112,7 +108,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
override fun chapterListParse(response: Response, chapters: MutableList<Chapter>) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
with(map.chapters) {
|
with(map.chapters) {
|
||||||
val pool = emptyMap<String, Element>()
|
val pool = emptyMap<String, Element>()
|
||||||
val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH)
|
val dateFormat = SimpleDateFormat(date?.format, Locale.ENGLISH)
|
||||||
|
@ -131,7 +127,7 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
with(map.pages) {
|
with(map.pages) {
|
||||||
val url = response.request().url().toString()
|
val url = response.request().url().toString()
|
||||||
pages_css?.let {
|
pages_css?.let {
|
||||||
|
@ -143,20 +139,16 @@ class YamlOnlineSource(context: Context, mappings: Map<*, *>) : OnlineSource(con
|
||||||
}
|
}
|
||||||
|
|
||||||
for ((i, element) in document.select(image_css).withIndex()) {
|
for ((i, element) in document.select(image_css).withIndex()) {
|
||||||
pages.getOrNull(i)?.imageUrl = element.attr(image_attr).let {
|
pages.getOrNull(i)?.imageUrl = element.absUrl(image_attr)
|
||||||
getAbsoluteUrl(it, response.request().url())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun imageUrlParse(response: Response): String {
|
override fun imageUrlParse(response: Response): String {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
return with(map.pages) {
|
return with(map.pages) {
|
||||||
document.select(image_css).first().attr(image_attr).let {
|
document.select(image_css).first().absUrl(image_attr)
|
||||||
getAbsoluteUrl(it, response.request().url())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,11 +14,11 @@ import eu.kanade.tachiyomi.data.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.LoginSource
|
import eu.kanade.tachiyomi.data.source.online.LoginSource
|
||||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import eu.kanade.tachiyomi.util.selectText
|
import eu.kanade.tachiyomi.util.selectText
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
@ -60,7 +60,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
||||||
override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1"
|
override fun popularMangaInitialUrl() = "$baseUrl/search_ajax?order_cond=views&order=desc&p=1"
|
||||||
|
|
||||||
override fun popularMangaParse(response: Response, page: MangasPage) {
|
override fun popularMangaParse(response: Response, page: MangasPage) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
for (element in document.select(popularMangaSelector())) {
|
for (element in document.select(popularMangaSelector())) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
popularMangaFromElement(element, this)
|
popularMangaFromElement(element, this)
|
||||||
|
@ -87,7 +87,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
||||||
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=1"
|
override fun searchMangaInitialUrl(query: String) = "$baseUrl/search_ajax?name=${Uri.encode(query)}&p=1"
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
override fun searchMangaParse(response: Response, page: MangasPage, query: String) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
for (element in document.select(searchMangaSelector())) {
|
for (element in document.select(searchMangaSelector())) {
|
||||||
Manga.create(id).apply {
|
Manga.create(id).apply {
|
||||||
searchMangaFromElement(element, this)
|
searchMangaFromElement(element, this)
|
||||||
|
@ -139,7 +139,7 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
||||||
throw Exception(notice)
|
throw Exception(notice)
|
||||||
}
|
}
|
||||||
|
|
||||||
val document = Jsoup.parse(body)
|
val document = response.asJsoup(body)
|
||||||
|
|
||||||
for (element in document.select(chapterListSelector())) {
|
for (element in document.select(chapterListSelector())) {
|
||||||
Chapter.create().apply {
|
Chapter.create().apply {
|
||||||
|
@ -221,11 +221,11 @@ class Batoto(context: Context, override val id: Int) : ParsedOnlineSource(contex
|
||||||
override fun login(username: String, password: String) =
|
override fun login(username: String, password: String) =
|
||||||
client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global§ion=login", headers))
|
client.newCall(GET("$baseUrl/forums/index.php?app=core&module=global§ion=login", headers))
|
||||||
.asObservable()
|
.asObservable()
|
||||||
.flatMap { doLogin(it.body().string(), username, password) }
|
.flatMap { doLogin(it, username, password) }
|
||||||
.map { isAuthenticationSuccessful(it) }
|
.map { isAuthenticationSuccessful(it) }
|
||||||
|
|
||||||
private fun doLogin(response: String, username: String, password: String): Observable<Response> {
|
private fun doLogin(response: Response, username: String, password: String): Observable<Response> {
|
||||||
val doc = Jsoup.parse(response)
|
val doc = response.asJsoup()
|
||||||
val form = doc.select("#login").first()
|
val form = doc.select("#login").first()
|
||||||
val url = form.attr("action")
|
val url = form.attr("action")
|
||||||
val authKey = form.select("input[name=auth_key]").first()
|
val authKey = form.select("input[name=auth_key]").first()
|
||||||
|
|
|
@ -7,8 +7,8 @@ import eu.kanade.tachiyomi.data.source.EN
|
||||||
import eu.kanade.tachiyomi.data.source.Language
|
import eu.kanade.tachiyomi.data.source.Language
|
||||||
import eu.kanade.tachiyomi.data.source.model.Page
|
import eu.kanade.tachiyomi.data.source.model.Page
|
||||||
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
import eu.kanade.tachiyomi.data.source.online.ParsedOnlineSource
|
||||||
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.text.ParseException
|
import java.text.ParseException
|
||||||
|
@ -105,7 +105,7 @@ class Mangafox(context: Context, override val id: Int) : ParsedOnlineSource(cont
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
override fun pageListParse(response: Response, pages: MutableList<Page>) {
|
||||||
val document = Jsoup.parse(response.body().string())
|
val document = response.asJsoup()
|
||||||
|
|
||||||
val url = response.request().url().toString().substringBeforeLast('/')
|
val url = response.request().url().toString().substringBeforeLast('/')
|
||||||
document.select("select.m").first().select("option:not([value=0])").forEach {
|
document.select("select.m").first().select("option:not([value=0])").forEach {
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
package eu.kanade.tachiyomi.util
|
package eu.kanade.tachiyomi.util
|
||||||
|
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
fun Element.selectText(css: String, defaultValue: String? = null): String? {
|
fun Element.selectText(css: String, defaultValue: String? = null): String? {
|
||||||
|
@ -10,3 +13,10 @@ fun Element.selectInt(css: String, defaultValue: Int = 0): Int {
|
||||||
return select(css).first()?.text()?.toInt() ?: defaultValue
|
return select(css).first()?.text()?.toInt() ?: defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Jsoup document for this response.
|
||||||
|
* @param html the body of the response. Use only if the body was read before calling this method.
|
||||||
|
*/
|
||||||
|
fun Response.asJsoup(html: String? = null): Document {
|
||||||
|
return Jsoup.parse(html ?: body().string(), request().url().toString())
|
||||||
|
}
|
Loading…
Reference in a new issue