diff --git a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml index c92603d4..663e045e 100644 --- a/server/i18n/src/commonMain/moko-resources/values/base/strings.xml +++ b/server/i18n/src/commonMain/moko-resources/values/base/strings.xml @@ -91,8 +91,10 @@ Catalog Root Search Catalog + First Page Previous Page Next Page + Last Page Current Feed View on Web Read Online diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt index 955736ae..28e4e947 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/constants/OpdsConstants.kt @@ -25,8 +25,10 @@ object OpdsConstants { const val LINK_REL_ALTERNATE = "alternate" const val LINK_REL_FACET = "http://opds-spec.org/facet" const val LINK_REL_SEARCH = "search" - const val LINK_REL_PREV = "prev" + const val LINK_REL_PREV = "previous" const val LINK_REL_NEXT = "next" + const val LINK_REL_FIRST = "first" + const val LINK_REL_LAST = "last" const val LINK_REL_PSE_STREAM = "http://vaemendis.net/opds-pse/stream" const val LINK_REL_CRAWLABLE = "http://opds-spec.org/crawlable" const val LINK_REL_SORT_NEW = "http://opds-spec.org/sort/new" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt index 7c65d804..f2dd21ab 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/controller/OpdsV1Controller.kt @@ -34,6 +34,7 @@ object OpdsV1Controller { ctx: Context, pageNum: Int?, criteria: OpdsMangaFilter, + isSearch: Boolean, ) { val locale: Locale = LocalizationHelper.ctxToLocale(ctx, ctx.queryParam("lang")) ctx.future { @@ -45,6 +46,7 @@ object OpdsV1Controller { sort = criteria.sort, filter = criteria.filter, locale = locale, + isSearch = isSearch, ) }.thenApply { xml -> ctx.contentType(OPDS_MIME).result(xml) @@ -126,7 +128,7 @@ object OpdsV1Controller { UTF-8 + template="$BASE_URL/library/series?query={searchTerms}&lang=${locale.toLanguageTag()}"/> """.trimIndent(), ) @@ -149,8 +151,9 @@ object OpdsV1Controller { val title = ctx.queryParam("title") val lang = ctx.queryParam("lang") val locale: Locale = LocalizationHelper.ctxToLocale(ctx, lang) + val isSearch = query != null || author != null || title != null - if (query != null || author != null || title != null) { + if (isSearch) { val opdsSearchCriteria = OpdsSearchCriteria(query, author, title) ctx.future { future { @@ -175,6 +178,7 @@ object OpdsV1Controller { ctx, pageNumber, criteria, + isSearch = false, ) } }, @@ -422,7 +426,7 @@ object OpdsV1Controller { behaviorOf = { ctx, sourceId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(sourceId = sourceId, primaryFilter = PrimaryFilterType.SOURCE)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -441,7 +445,7 @@ object OpdsV1Controller { ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(categoryId = categoryId, primaryFilter = PrimaryFilterType.CATEGORY)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -459,7 +463,7 @@ object OpdsV1Controller { behaviorOf = { ctx, genre -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(genre = genre, primaryFilter = PrimaryFilterType.GENRE)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -477,7 +481,7 @@ object OpdsV1Controller { behaviorOf = { ctx, statusId -> ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(statusId = statusId, primaryFilter = PrimaryFilterType.STATUS)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) @@ -501,7 +505,7 @@ object OpdsV1Controller { ctx.getAttribute(Attribute.TachideskUser).requireUser() val criteria = buildCriteriaFromContext(ctx, OpdsMangaFilter(langCode = langCode, primaryFilter = PrimaryFilterType.LANGUAGE)) - getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria) + getLibraryFeed(ctx, ctx.queryParam("pageNumber")?.toIntOrNull(), criteria, isSearch = false) }, withResults = { httpCode(HttpStatus.OK) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt index fc9d6550..3b18750e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/FeedBuilderInternal.kt @@ -9,20 +9,22 @@ import suwayomi.tachidesk.opds.model.OpdsLinkXml import suwayomi.tachidesk.opds.util.OpdsDateUtil import suwayomi.tachidesk.server.serverConfig import java.util.Locale +import kotlin.math.ceil /** - * Clase de ayuda para construir un OpdsFeedXml. + * Helper class to build an OpdsFeedXml. */ class FeedBuilderInternal( - val baseUrl: String, - val idPath: String, - val title: String, - val locale: Locale, - val feedType: String, - var pageNum: Int? = 1, - var explicitQueryParams: String? = null, - val currentSort: String? = null, - val currentFilter: String? = null, + private val baseUrl: String, + private val idPath: String, + private val title: String, + private val locale: Locale, + private val feedType: String, + private val pageNum: Int? = 1, + private val explicitQueryParams: String? = null, + private val currentSort: String? = null, + private val currentFilter: String? = null, + private val isSearchFeed: Boolean = false, ) { private val opdsItemsPerPageBounded: Int get() = serverConfig.opdsItemsPerPage.value.coerceIn(10, 5000) @@ -55,8 +57,7 @@ class FeedBuilderInternal( } fun build(): OpdsFeedXml { - val actualPageNum = pageNum ?: 1 - val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) actualPageNum else null) + val selfLinkHref = buildUrlWithParams(idPath, if (pageNum != null) pageNum else null) val feedLinks = mutableListOf() feedLinks.addAll(this.links) @@ -80,24 +81,54 @@ class FeedBuilderInternal( ), ) + // Add pagination links if needed if (pageNum != null) { - if (actualPageNum > 1) { + val totalPages = ceil(totalResults.toDouble() / opdsItemsPerPageBounded).toInt() + + if (totalPages > 1) { + val currentPage = pageNum.coerceAtLeast(1) + + // Always add 'first' link when there are multiple pages feedLinks.add( OpdsLinkXml( - OpdsConstants.LINK_REL_PREV, - buildUrlWithParams(idPath, actualPageNum - 1), + OpdsConstants.LINK_REL_FIRST, + buildUrlWithParams(idPath, 1), feedType, - MR.strings.opds_linktitle_previous_page.localized(locale), + MR.strings.opds_linktitle_first_page.localized(locale), ), ) - } - if (totalResults > actualPageNum * opdsItemsPerPageBounded) { + + // Add 'prev' link if not on first page + if (currentPage > 1) { + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_PREV, + buildUrlWithParams(idPath, currentPage - 1), + feedType, + MR.strings.opds_linktitle_previous_page.localized(locale), + ), + ) + } + + // Add 'next' link if not on last page + if (currentPage < totalPages) { + feedLinks.add( + OpdsLinkXml( + OpdsConstants.LINK_REL_NEXT, + buildUrlWithParams(idPath, currentPage + 1), + feedType, + MR.strings.opds_linktitle_next_page.localized(locale), + ), + ) + } + + // Always add 'last' link when there are multiple pages feedLinks.add( OpdsLinkXml( - OpdsConstants.LINK_REL_NEXT, - buildUrlWithParams(idPath, actualPageNum + 1), + OpdsConstants.LINK_REL_LAST, + buildUrlWithParams(idPath, totalPages), feedType, - MR.strings.opds_linktitle_next_page.localized(locale), + MR.strings.opds_linktitle_last_page.localized(locale), ), ) } @@ -111,7 +142,7 @@ class FeedBuilderInternal( currentFilter?.let { urnParams.add("filter_$it") } val urnSuffix = if (urnParams.isNotEmpty()) ":${urnParams.joinToString(":")}" else "" - val showPaginationFields = pageNum != null && totalResults > 0 + val showOpenSearchFields = isSearchFeed && pageNum != null && totalResults > 0 return OpdsFeedXml( id = "urn:suwayomi:feed:${idPath.replace('/',':')}$urnSuffix", @@ -121,9 +152,9 @@ class FeedBuilderInternal( author = feedAuthor, links = feedLinks, entries = entries, - totalResults = totalResults.takeIf { showPaginationFields }, - itemsPerPage = if (showPaginationFields) opdsItemsPerPageBounded else null, - startIndex = if (showPaginationFields) ((actualPageNum - 1) * opdsItemsPerPageBounded + 1) else null, + totalResults = totalResults.takeIf { showOpenSearchFields }, + itemsPerPage = if (showOpenSearchFields) opdsItemsPerPageBounded else null, + startIndex = if (showOpenSearchFields) ((pageNum - 1) * opdsItemsPerPageBounded) + 1 else null, ) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt index c1e2fbc1..cb8acb79 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/opds/impl/OpdsFeedBuilder.kt @@ -125,6 +125,7 @@ object OpdsFeedBuilder { locale, OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, pageNum, + isSearchFeed = true, ) builder.totalResults = total builder.entries.addAll(mangaEntries.map { OpdsEntryBuilder.mangaAcqEntryToEntry(it, baseUrl, locale) }) @@ -148,6 +149,7 @@ object OpdsFeedBuilder { sort: String?, filter: String?, locale: Locale, + isSearch: Boolean, ): String { val result = MangaRepository.getLibraryManga(pageNum, sort, filter, criteria) @@ -200,6 +202,7 @@ object OpdsFeedBuilder { currentSort = criteria.sort, currentFilter = criteria.filter, explicitQueryParams = criteria.toCrossFilterQueryParameters(), + isSearchFeed = isSearch, ) builder.totalResults = result.totalCount