package suwayomi.tachidesk.opds.impl import dev.icerock.moko.resources.StringResource import suwayomi.tachidesk.i18n.MR import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.sync.KoreaderSyncService import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.opds.constants.OpdsConstants import suwayomi.tachidesk.opds.dto.OpdsChapterListAcqEntry import suwayomi.tachidesk.opds.dto.OpdsChapterMetadataAcqEntry import suwayomi.tachidesk.opds.dto.OpdsMangaAcqEntry import suwayomi.tachidesk.opds.dto.OpdsMangaDetails import suwayomi.tachidesk.opds.dto.OpdsMangaFilter import suwayomi.tachidesk.opds.dto.PrimaryFilterType import suwayomi.tachidesk.opds.model.OpdsAuthorXml import suwayomi.tachidesk.opds.model.OpdsCategoryXml import suwayomi.tachidesk.opds.model.OpdsContentXml import suwayomi.tachidesk.opds.model.OpdsEntryXml import suwayomi.tachidesk.opds.model.OpdsLinkXml import suwayomi.tachidesk.opds.model.OpdsSummaryXml import suwayomi.tachidesk.opds.repository.MangaRepository import suwayomi.tachidesk.opds.repository.NavigationRepository import suwayomi.tachidesk.opds.util.OpdsDateUtil import suwayomi.tachidesk.opds.util.OpdsStringUtil.formatFileSizeForOpds import suwayomi.tachidesk.server.serverConfig import java.util.Locale /** * A builder class responsible for creating OPDS Entry XML objects from data transfer objects. */ object OpdsEntryBuilder { private fun currentFormattedTime() = OpdsDateUtil.formatCurrentInstantForOpds() /** * Builds a concise summary for a manga entry, including status, source, and language. * @param entry The manga data object. * @param locale The locale for localization. * @return A formatted summary string. */ private fun buildMangaSummary( entry: OpdsMangaAcqEntry, locale: Locale, ): String { val summaryParts = mutableListOf() val statusKey = when (MangaStatus.valueOf(entry.status)) { MangaStatus.ONGOING -> MR.strings.manga_status_ongoing MangaStatus.COMPLETED -> MR.strings.manga_status_completed MangaStatus.LICENSED -> MR.strings.manga_status_licensed MangaStatus.PUBLISHING_FINISHED -> MR.strings.manga_status_publishing_finished MangaStatus.CANCELLED -> MR.strings.manga_status_cancelled MangaStatus.ON_HIATUS -> MR.strings.manga_status_on_hiatus else -> MR.strings.manga_status_unknown } summaryParts.add(MR.strings.opds_manga_summary_status.localized(locale, statusKey.localized(locale))) summaryParts.add(MR.strings.opds_manga_summary_source.localized(locale, entry.sourceName)) summaryParts.add(MR.strings.opds_manga_summary_language.localized(locale, entry.sourceLang)) return summaryParts.joinToString(" | ") } /** * Adds a facet link to the feed builder. * @param feedBuilder The feed builder to add the link to. * @param href The URL for the facet link. * @param title The title of the facet. * @param group The group this facet belongs to. * @param isActive Whether this facet is currently active. * @param count The number of items in this facet. */ private fun addFacet( feedBuilder: FeedBuilderInternal, href: String, title: String, group: String, isActive: Boolean, count: Long?, ) { feedBuilder.links.add( OpdsLinkXml( OpdsConstants.LINK_REL_FACET, href, OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, title, facetGroup = group, activeFacet = isActive, thrCount = count?.toInt(), ), ) } /** * Converts a manga data object into a full OPDS acquisition entry. * @param baseUrl The base URL for constructing links. * @param locale The locale for localization. * @param entry The manga data object. * @return An [OpdsEntryXml] object representing the manga. */ fun mangaAcqEntryToEntry( baseUrl: String, locale: Locale, entry: OpdsMangaAcqEntry, ): OpdsEntryXml { val displayThumbnailUrl = entry.thumbnailUrl?.let { proxyThumbnailUrl(entry.id) } val categoryScheme = if (entry.inLibrary) "$baseUrl/library/genres" else "$baseUrl/genres" val links = mutableListOf() links.add( OpdsLinkXml( OpdsConstants.LINK_REL_SUBSECTION, "$baseUrl/series/${entry.id}/chapters?lang=${locale.toLanguageTag()}", OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, entry.title, ), ) entry.url?.let { links.add( OpdsLinkXml( OpdsConstants.LINK_REL_ALTERNATE, it, "text/html", MR.strings.opds_linktitle_view_on_web.localized(locale), ), ) } displayThumbnailUrl?.let { links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE, it, OpdsConstants.TYPE_IMAGE_JPEG)) links.add(OpdsLinkXml(OpdsConstants.LINK_REL_IMAGE_THUMBNAIL, it, OpdsConstants.TYPE_IMAGE_JPEG)) } val summaryText = buildMangaSummary(entry, locale) return OpdsEntryXml( id = "urn:suwayomi:manga:${entry.id}", title = entry.title, updated = OpdsDateUtil.formatEpochMillisForOpds(entry.lastFetchedAt * 1000), authors = entry.author?.let { listOf(OpdsAuthorXml(name = it)) }, categories = entry.genres.filter { it.isNotBlank() }.map { genre -> OpdsCategoryXml( term = genre.lowercase().replace(" ", "_"), label = genre, scheme = categoryScheme, ) }, summary = OpdsSummaryXml(value = summaryText), content = entry.description?.let { OpdsContentXml(type = "text", value = it) }, link = links, publisher = entry.sourceName, language = entry.sourceLang, ) } /** * Resolves the display title for a chapter, ensuring an empty name falls back to * a generic "Chapter X" or "Oneshot" to keep the OPDS feed clean and usable. */ private fun resolveChapterTitle( chapterName: String, chapterNumber: Float, sourceOrder: Int, totalChapters: Long, locale: Locale, ): String { val trimmedName = chapterName.trim() if (trimmedName.isNotEmpty()) { return trimmedName } return if (totalChapters <= 1L) { MR.strings.opds_chapter_title_oneshot.localized(locale) } else { val formatNumber = if (chapterNumber >= 0f) { if (chapterNumber % 1 == 0f) chapterNumber.toInt().toString() else chapterNumber.toString() } else { sourceOrder.toString() } MR.strings.opds_chapter_title_fallback.localized(locale, formatNumber) } } /** * Creates an OPDS entry for a chapter, including acquisition and streaming links. * @param baseUrl The base URL for constructing links. * @param locale The locale for localization. * @param chapter The chapter data object. * @param manga The parent manga's details. * @param addMangaTitle Whether to prepend the manga title to the entry title. * @param skipMetadataFeed Whether to skip the metadata feed. * @return An [OpdsEntryXml] object for the chapter. */ suspend fun createChapterListEntry( baseUrl: String, locale: Locale, chapter: OpdsChapterListAcqEntry, manga: OpdsMangaDetails, addMangaTitle: Boolean, skipMetadataFeed: Boolean, ): OpdsEntryXml { var effectiveLastPageRead = chapter.lastPageRead var effectiveLastReadAt = chapter.lastReadAt if (skipMetadataFeed) { val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id) // If sync strategy dictates an update (e.g. KEEP_REMOTE), use remote data. // If sync strategy is PROMPT (isConflict=true), we ignore it here (effectively KEEP_LOCAL/DISABLED) // because we cannot show a prompt in the chapter list feed. if (syncResult != null && syncResult.shouldUpdate) { effectiveLastPageRead = syncResult.pageRead effectiveLastReadAt = syncResult.timestamp } } val statusKey = when { chapter.downloaded -> MR.strings.opds_chapter_status_downloaded chapter.read -> MR.strings.opds_chapter_status_read effectiveLastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress else -> MR.strings.opds_chapter_status_unread } val titlePrefix = statusKey.localized(locale) val chapterName = resolveChapterTitle(chapter.name, chapter.chapterNumber, chapter.sourceOrder, manga.totalChapters, locale) val entryTitle = titlePrefix + (if (addMangaTitle) " ${manga.title}:" else "") + " $chapterName" val details = buildString { append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapterName)) chapter.scanlator?.takeIf { it.isNotBlank() }?.let { append(MR.strings.opds_chapter_details_scanlator.localized(locale, it)) } if (chapter.pageCount > 0) { append(MR.strings.opds_chapter_details_progress.localized(locale, effectiveLastPageRead, chapter.pageCount)) } } val links = mutableListOf() if (skipMetadataFeed) { // Provide Acquisition Link (Download CBZ) if downloaded if (chapter.downloaded) { links.add( OpdsLinkXml( OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS, "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}", serverConfig.opdsCbzMimetype.value.mediaType, MR.strings.opds_linktitle_download_cbz.localized(locale), length = chapter.cbzFileSize, ), ) } // Provide Stream Link (OPDS-PSE) if page count is known if (chapter.pageCount > 0) { val basePageHref = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" + "?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}&opds=true" val titleRes = if (effectiveLastPageRead > 0) { MR.strings.opds_linktitle_stream_pages_continue } else { MR.strings.opds_linktitle_stream_pages_start } links.add( OpdsLinkXml( rel = OpdsConstants.LINK_REL_PSE_STREAM, href = basePageHref, type = OpdsConstants.TYPE_IMAGE_JPEG, title = titleRes.localized(locale), pseCount = chapter.pageCount, pseLastRead = effectiveLastPageRead.takeIf { it > 0 }, pseLastReadDate = effectiveLastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) }, ), ) // Page 0 Cover links.add( OpdsLinkXml( rel = OpdsConstants.LINK_REL_IMAGE, href = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0", type = OpdsConstants.TYPE_IMAGE_JPEG, title = MR.strings.opds_linktitle_chapter_cover.localized(locale), ), ) } } else { // Link to Metadata Feed links.add( OpdsLinkXml( rel = OpdsConstants.LINK_REL_SUBSECTION, href = "$baseUrl/series/${manga.id}/chapter/${chapter.sourceOrder}/metadata?lang=${locale.toLanguageTag()}", type = OpdsConstants.TYPE_ATOM_XML_ENTRY_PROFILE_OPDS, title = MR.strings.opds_linktitle_view_chapter_details.localized(locale), ), ) } return OpdsEntryXml( id = "urn:suwayomi:chapter:${chapter.id}", title = entryTitle, updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate), authors = listOfNotNull( manga.author?.let { OpdsAuthorXml(name = it) }, chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, ), summary = OpdsSummaryXml(value = details), link = links, ) } /** * Creates one or two OPDS entries for a chapter, handling synchronization conflicts internally. * * @param baseUrl The base URL for constructing links. * @param locale The locale for localization. * @param chapter The chapter metadata object. * @param manga The parent manga's details. * @return A `Pair` where the first element is the primary entry (always present) and the * second is an optional entry representing the remote progress in case of a conflict. */ suspend fun createChapterMetadataEntries( baseUrl: String, locale: Locale, chapter: OpdsChapterMetadataAcqEntry, manga: OpdsMangaDetails, ): Pair { // Check remote progress before building the entry val syncResult = KoreaderSyncService.checkAndPullProgress(chapter.id) // Exists a conflict if the sync service reports a conflict and the page numbers differ. val hasConflict = syncResult?.isConflict == true && syncResult.pageRead != chapter.lastPageRead if (hasConflict) { // Generate two entries: one for local progress and another for remote. val localEntry = buildSingleChapterMetadataEntry( baseUrl, locale, chapter, manga, progressSource = ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt), isConflict = true, ) val remoteEntry = buildSingleChapterMetadataEntry( baseUrl, locale, chapter, manga, progressSource = ProgressSource.Remote(syncResult!!.pageRead, syncResult.timestamp, syncResult.device), isConflict = true, ) return Pair(localEntry, remoteEntry) } else { // No conflict, generate a single entry. Use remote progress if a silent update occurred. val progressSource = if (syncResult?.shouldUpdate == true) { ProgressSource.Remote(syncResult.pageRead, syncResult.timestamp, syncResult.device) } else { ProgressSource.Local(chapter.lastPageRead, chapter.lastReadAt) } val mainEntry = buildSingleChapterMetadataEntry( baseUrl, locale, chapter, manga, progressSource = progressSource, isConflict = false, ) return Pair(mainEntry, null) } } /** * Represents the source of progress information for a chapter. */ private sealed class ProgressSource { abstract val lastPageRead: Int abstract val lastReadAt: Long data class Local( override val lastPageRead: Int, override val lastReadAt: Long, ) : ProgressSource() data class Remote( override val lastPageRead: Int, override val lastReadAt: Long, val device: String, ) : ProgressSource() } /** * Helper function to build a single OpdsEntryXml for a chapter. */ private suspend fun buildSingleChapterMetadataEntry( baseUrl: String, locale: Locale, chapter: OpdsChapterMetadataAcqEntry, manga: OpdsMangaDetails, progressSource: ProgressSource, isConflict: Boolean, ): OpdsEntryXml { val idSuffix: String val titlePrefix: String when (progressSource) { is ProgressSource.Local -> { idSuffix = "" // No suffix for the primary/local entry val statusKey = when { chapter.downloaded -> MR.strings.opds_chapter_status_downloaded chapter.read -> MR.strings.opds_chapter_status_read progressSource.lastPageRead > 0 -> MR.strings.opds_chapter_status_in_progress else -> MR.strings.opds_chapter_status_unread } titlePrefix = statusKey.localized(locale) } is ProgressSource.Remote -> { idSuffix = ":remote" titlePrefix = MR.strings.opds_chapter_status_synced.localized(locale, progressSource.device) } } val details = buildString { append(MR.strings.opds_chapter_details_base.localized(locale, manga.title, chapter.name)) chapter.scanlator?.takeIf { it.isNotBlank() }?.let { append(MR.strings.opds_chapter_details_scanlator.localized(locale, it)) } val pageCountDisplay = chapter.pageCount.takeIf { it > 0 } ?: "?" append(MR.strings.opds_chapter_details_progress.localized(locale, progressSource.lastPageRead, pageCountDisplay)) } val entryTitle = "$titlePrefix ${chapter.name}" val cbzFileSize = chapter.cbzFileSize val links = mutableListOf() chapter.url?.let { links.add( OpdsLinkXml(OpdsConstants.LINK_REL_ALTERNATE, it, "text/html", MR.strings.opds_linktitle_view_on_web.localized(locale)), ) } if (chapter.downloaded) { links.add( OpdsLinkXml( OpdsConstants.LINK_REL_ACQUISITION_OPEN_ACCESS, "/api/v1/chapter/${chapter.id}/download?markAsRead=${serverConfig.opdsMarkAsReadOnDownload.value}", serverConfig.opdsCbzMimetype.value.mediaType, MR.strings.opds_linktitle_download_cbz.localized(locale), length = cbzFileSize, ), ) } if (chapter.pageCount > 0) { val basePageHref = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/{pageNumber}" + "?updateProgress=${serverConfig.opdsEnablePageReadProgress.value}&opds=true" val title: String = when { !isConflict -> { val titleRes = if (progressSource.lastPageRead > 0) { MR.strings.opds_linktitle_stream_pages_continue } else { MR.strings.opds_linktitle_stream_pages_start } titleRes.localized(locale) } progressSource is ProgressSource.Local -> { val titleRes = if (progressSource.lastPageRead > 0) { MR.strings.opds_linktitle_stream_pages_continue_local } else { MR.strings.opds_linktitle_stream_pages_start_local } titleRes.localized(locale) } progressSource is ProgressSource.Remote -> { val titleRes = if (progressSource.lastPageRead > 0) { MR.strings.opds_linktitle_stream_pages_continue_remote } else { MR.strings.opds_linktitle_stream_pages_start_remote } titleRes.localized(locale, progressSource.device) } else -> { // Should not happen "" } } links.add( OpdsLinkXml( rel = OpdsConstants.LINK_REL_PSE_STREAM, href = basePageHref, type = OpdsConstants.TYPE_IMAGE_JPEG, title = title, pseCount = chapter.pageCount, pseLastRead = progressSource.lastPageRead.takeIf { it > 0 }, pseLastReadDate = progressSource.lastReadAt.takeIf { it > 0 }?.let { OpdsDateUtil.formatEpochMillisForOpds(it * 1000) }, ), ) links.add( OpdsLinkXml( rel = OpdsConstants.LINK_REL_IMAGE, href = "/api/v1/manga/${manga.id}/chapter/${chapter.sourceOrder}/page/0", type = OpdsConstants.TYPE_IMAGE_JPEG, title = MR.strings.opds_linktitle_chapter_cover.localized(locale), ), ) } return OpdsEntryXml( id = "urn:suwayomi:chapter:${chapter.id}:metadata$idSuffix", title = entryTitle, updated = OpdsDateUtil.formatEpochMillisForOpds(chapter.uploadDate), authors = listOfNotNull( manga.author?.let { OpdsAuthorXml(name = it) }, chapter.scanlator?.takeIf { it.isNotBlank() }?.let { OpdsAuthorXml(name = it) }, ), summary = OpdsSummaryXml(value = details), link = links, extent = cbzFileSize?.let { formatFileSizeForOpds(it) }, format = if (cbzFileSize != null) "CBZ" else null, ) } /** * Adds sorting facet links for an 'Explore Source' feed. */ fun addSourceSortFacets( feedBuilder: FeedBuilderInternal, baseUrl: String, locale: Locale, currentSort: String, ) { val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) val addFacet = { href: String, titleKey: StringResource, isActive: Boolean -> feedBuilder.links.add( OpdsLinkXml( OpdsConstants.LINK_REL_FACET, href, OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, titleKey.localized(locale), facetGroup = sortGroup, activeFacet = isActive, ), ) } addFacet("$baseUrl?sort=popular&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_popular, currentSort == "popular") addFacet("$baseUrl?sort=latest&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_latest, currentSort == "latest") } /** * Adds sorting and filtering facet links for a chapter feed. */ fun addChapterSortAndFilterFacets( feedBuilder: FeedBuilderInternal, baseUrl: String, locale: Locale, currentSort: String, currentFilter: String, filterCounts: Map? = null, ) { val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) val filterGroup = MR.strings.opds_facetgroup_filter_read_status.localized(locale) val addSortFacet = { href: String, titleKey: StringResource, group: String, isActive: Boolean -> feedBuilder.links.add( OpdsLinkXml( OpdsConstants.LINK_REL_FACET, href, OpdsConstants.TYPE_ATOM_XML_FEED_ACQUISITION, titleKey.localized(locale), facetGroup = group, activeFacet = isActive, ), ) } addSortFacet( "$baseUrl?sort=number_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_oldest_first, sortGroup, currentSort == "number_asc", ) addSortFacet( "$baseUrl?sort=number_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_newest_first, sortGroup, currentSort == "number_desc", ) addSortFacet( "$baseUrl?sort=date_asc&filter=$currentFilter&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_date_asc, sortGroup, currentSort == "date_asc", ) addSortFacet( "$baseUrl?sort=date_desc&filter=$currentFilter&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_sort_date_desc, sortGroup, currentSort == "date_desc", ) addFacet( feedBuilder, "$baseUrl?filter=all&sort=$currentSort&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_filter_all_chapters.localized(locale), filterGroup, currentFilter == "all", filterCounts?.get("all"), ) addFacet( feedBuilder, "$baseUrl?filter=unread&sort=$currentSort&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_filter_unread_only.localized(locale), filterGroup, currentFilter == "unread", filterCounts?.get("unread"), ) addFacet( feedBuilder, "$baseUrl?filter=read&sort=$currentSort&lang=${locale.toLanguageTag()}", MR.strings.opds_facet_filter_read_only.localized(locale), filterGroup, currentFilter == "read", filterCounts?.get("read"), ) } /** * Adds a comprehensive set of facet links for library feeds, covering sorting, content filtering, * and cross-filtering by source, category, status, language, and genre. */ fun addLibraryFacets( feedBuilder: FeedBuilderInternal, baseUrl: String, locale: Locale, activeFilters: OpdsMangaFilter, ) { val currentSort = activeFilters.sort ?: "alpha_asc" val currentFilter = activeFilters.filter ?: "all" val sortGroup = MR.strings.opds_facetgroup_sort_order.localized(locale) val filterGroup = MR.strings.opds_facetgroup_filter_content.localized(locale) val filterCounts = MangaRepository.getLibraryFilterCounts(activeFilters) val buildUrl = { newFilters: OpdsMangaFilter, newSort: String, newFilter: String -> val crossFilterParams = newFilters.toCrossFilterQueryParameters() val sortParam = "sort=$newSort" val filterParam = "filter=$newFilter" val langParam = "lang=${locale.toLanguageTag()}" val allParams = listOfNotNull(crossFilterParams, sortParam, filterParam, langParam).filter { it.isNotEmpty() } "$baseUrl/library/series?${allParams.joinToString("&")}" } // --- Sort Facets --- addFacet( feedBuilder, buildUrl(activeFilters, "alpha_asc", currentFilter), MR.strings.opds_facet_sort_alpha_asc.localized(locale), sortGroup, currentSort == "alpha_asc", null, ) addFacet( feedBuilder, buildUrl(activeFilters, "alpha_desc", currentFilter), MR.strings.opds_facet_sort_alpha_desc.localized(locale), sortGroup, currentSort == "alpha_desc", null, ) addFacet( feedBuilder, buildUrl(activeFilters, "last_read_desc", currentFilter), MR.strings.opds_facet_sort_last_read_desc.localized(locale), sortGroup, currentSort == "last_read_desc", null, ) addFacet( feedBuilder, buildUrl(activeFilters, "latest_chapter_desc", currentFilter), MR.strings.opds_facet_sort_latest_chapter_desc.localized(locale), sortGroup, currentSort == "latest_chapter_desc", null, ) addFacet( feedBuilder, buildUrl(activeFilters, "date_added_desc", currentFilter), MR.strings.opds_facet_sort_date_added_desc.localized(locale), sortGroup, currentSort == "date_added_desc", null, ) addFacet( feedBuilder, buildUrl(activeFilters, "unread_desc", currentFilter), MR.strings.opds_facet_sort_unread_desc.localized(locale), sortGroup, currentSort == "unread_desc", null, ) // --- Filter Facets --- addFacet( feedBuilder, buildUrl(activeFilters, currentSort, "all"), MR.strings.opds_facet_filter_all.localized(locale), filterGroup, currentFilter == "all", null, ) addFacet( feedBuilder, buildUrl(activeFilters, currentSort, "unread"), MR.strings.opds_facet_filter_unread_only.localized(locale), filterGroup, currentFilter == "unread", filterCounts["unread"], ) addFacet( feedBuilder, buildUrl(activeFilters, currentSort, "downloaded"), MR.strings.opds_facet_filter_downloaded.localized(locale), filterGroup, currentFilter == "downloaded", filterCounts["downloaded"], ) addFacet( feedBuilder, buildUrl(activeFilters, currentSort, "ongoing"), MR.strings.opds_facet_filter_ongoing.localized(locale), filterGroup, currentFilter == "ongoing", filterCounts["ongoing"], ) addFacet( feedBuilder, buildUrl(activeFilters, currentSort, "completed"), MR.strings.opds_facet_filter_completed.localized(locale), filterGroup, currentFilter == "completed", filterCounts["completed"], ) // --- Cross-Filter Facets --- if (activeFilters.primaryFilter != PrimaryFilterType.SOURCE) { val sources = NavigationRepository.getLibrarySources(pageNum = null, activeFilters = activeFilters).first addFacet( feedBuilder, buildUrl(activeFilters.without("source_id"), currentSort, currentFilter), MR.strings.opds_facet_all_sources.localized(locale), MR.strings.opds_facetgroup_filter_source.localized(locale), activeFilters.sourceId == null, null, ) sources.forEach { addFacet( feedBuilder, buildUrl(activeFilters.with("source_id", it.id.toString()), currentSort, currentFilter), it.name, MR.strings.opds_facetgroup_filter_source.localized(locale), activeFilters.sourceId == it.id, it.mangaCount, ) } } if (activeFilters.primaryFilter != PrimaryFilterType.CATEGORY) { val categories = NavigationRepository.getCategories(pageNum = null, activeFilters = activeFilters).first addFacet( feedBuilder, buildUrl(activeFilters.without("category_id"), currentSort, currentFilter), MR.strings.opds_facet_all_categories.localized(locale), MR.strings.opds_facetgroup_filter_category.localized(locale), activeFilters.categoryId == null, null, ) categories.forEach { addFacet( feedBuilder, buildUrl(activeFilters.with("category_id", it.id.toString()), currentSort, currentFilter), it.name, MR.strings.opds_facetgroup_filter_category.localized(locale), activeFilters.categoryId == it.id, it.mangaCount, ) } } if (activeFilters.primaryFilter != PrimaryFilterType.STATUS) { val statuses = NavigationRepository.getStatuses(locale, pageNum = null, activeFilters = activeFilters).first addFacet( feedBuilder, buildUrl(activeFilters.without("status_id"), currentSort, currentFilter), MR.strings.opds_facet_all_statuses.localized(locale), MR.strings.opds_facetgroup_filter_status.localized(locale), activeFilters.statusId == null, null, ) statuses.forEach { addFacet( feedBuilder, buildUrl(activeFilters.with("status_id", it.id.toString()), currentSort, currentFilter), it.title, MR.strings.opds_facetgroup_filter_status.localized(locale), activeFilters.statusId == it.id, it.mangaCount, ) } } if (activeFilters.primaryFilter != PrimaryFilterType.LANGUAGE) { val languages = NavigationRepository.getContentLanguages(locale, pageNum = null, activeFilters = activeFilters).first addFacet( feedBuilder, buildUrl(activeFilters.without("lang_code"), currentSort, currentFilter), MR.strings.opds_facet_all_languages.localized(locale), MR.strings.opds_facetgroup_filter_language.localized(locale), activeFilters.langCode == null, null, ) languages.forEach { addFacet( feedBuilder, buildUrl(activeFilters.with("lang_code", it.id), currentSort, currentFilter), it.title, MR.strings.opds_facetgroup_filter_language.localized(locale), activeFilters.langCode == it.id, it.mangaCount, ) } } if (activeFilters.primaryFilter != PrimaryFilterType.GENRE) { val genres = NavigationRepository.getGenres(locale, pageNum = null, activeFilters = activeFilters).first addFacet( feedBuilder, buildUrl(activeFilters.without("genre"), currentSort, currentFilter), MR.strings.opds_facet_all_genres.localized(locale), MR.strings.opds_facetgroup_filter_genre.localized(locale), activeFilters.genre == null, null, ) genres.forEach { addFacet( feedBuilder, buildUrl(activeFilters.with("genre", it.id), currentSort, currentFilter), it.title, MR.strings.opds_facetgroup_filter_genre.localized(locale), activeFilters.genre == it.id, it.mangaCount, ) } } } }