Download

Write a source

Filters

Filters are the controls in a source's search sheet. Katari draws them and keeps their state; the source declares them and turns them into a search.

The controls #

A filter is a class extending one of the controls below, all nested in EntryFilter. Katari changes its state as the user picks, and passes the whole list to getSearchContent.

abstract class Select<V>(name: String, values: Array<V>, state: Int = 0)
One choice from values. The state is the index of the choice.
abstract class Sort(name: String, values: Array<String>, state: Selection? = null)
A sort order and its direction. The state is a Sort.Selection(index, ascending), or null for the site's default.
abstract class Text(name: String, state: String = "")
A text field. The state is the text.
abstract class CheckBox(name: String, state: Boolean = false)
An on/off switch. The state is true or false.
abstract class TriState(name: String, state: Int = 0)
A choice to include, exclude or ignore, such as one genre. The state is STATE_IGNORE (0), STATE_INCLUDE (1) or STATE_EXCLUDE (2), also read with isIgnored(), isIncluded() and isExcluded().
abstract class Group<V>(name: String, state: List<V>)
A titled, collapsible set of other filters. The state is the list of filters inside it.
open class Header(name: String)
A heading between filters. It has no state.
open class Separator(name: String = "")
A line between filters. It has no state.

Three more are covered below: EntryDateFilter for dates, and Autocomplete and PagedGroup for options the site provides and long lists. EntryFilter and EntryFilterList are in eu.kanade.tachiyomi.source.entry; the metadata, dates and validation on this page are in eu.kanade.tachiyomi.source.entry.filter.

Here are Example Comics' filters. The metadata in each is explained in the next sections:

Kotlin
class SortFilter :
    EntryFilter.Select<String>("Sort by", arrayOf("Popular", "Newest", "A–Z")),
    EntryFilterMetadataProvider {
    override val filterMetadata = EntryFilterMetadata(
        id = "sort",
        role = EntryFilterRole.ORDERING,
        optionIds = listOf("popular", "newest", "title"),
    )
}

class StatusFilter :
    EntryFilter.Select<String>("Status", arrayOf("Any", "Ongoing", "Completed")),
    EntryFilterMetadataProvider {
    override val filterMetadata = EntryFilterMetadata(
        id = "status",
        optionIds = listOf("any", "ongoing", "completed"),
        neutralOptionId = "any",
    )
}

class Genre(name: String, val slug: String) : EntryFilter.TriState(name), EntryFilterMetadataProvider {
    override val filterMetadata = EntryFilterMetadata(id = "genre-$slug")
}

class GenreFilter(genres: List<Genre>) : EntryFilter.Group<Genre>("Genres", genres), EntryFilterMetadataProvider {
    override val filterMetadata = EntryFilterMetadata(id = "genres")
}

fun exampleComicsFilters() = EntryFilterList(
    SortFilter(),
    StatusFilter(),
    GenreFilter(listOf(Genre("Action", "action"), Genre("Romance", "romance"))),
)

getFilterList returns a new list each time. Katari works on its own copies of the filters and sets the user's values on yours only while it calls getSearchContent:

Kotlin
override fun getFilterList() = exampleComicsFilters()

In getSearchContent, read each filter's state and turn it into the site's parameters. The option IDs from the metadata make good parameter values:

Kotlin
private val EntryFilter.Select<*>.selectedOption: String
    get() = (this as EntryFilterMetadataProvider).filterMetadata.optionIds[state]

fun EntryFilterList.applyTo(url: HttpUrl.Builder) {
    forEach { filter ->
        when (filter) {
            is SortFilter -> url.addQueryParameter("sort", filter.selectedOption)
            is StatusFilter -> if (filter.selectedOption != "any") url.addQueryParameter("status", filter.selectedOption)
            is GenreFilter -> filter.state.forEach { genre ->
                when (genre.state) {
                    EntryFilter.TriState.STATE_INCLUDE -> url.addQueryParameter("genre", genre.slug)
                    EntryFilter.TriState.STATE_EXCLUDE -> url.addQueryParameter("exclude", genre.slug)
                }
            }
            else -> Unit
        }
    }
}

An exception thrown by a filter while Katari draws, counts or checks it affects only that filter: Katari carries on without its summary and checks, and says so under it.

Keep saved presets working #

Users can save a set of filter values as a filter preset, and feeds in Browse › Feeds are built on them. Without metadata, Katari finds a saved filter again by its label and its position among the filters, and a choice by its index. Renaming a filter, translating its label, moving it, or adding a choice before others then breaks the presets that use it, and the user is asked to repair each one.

EntryFilterMetadataProvider gives filters an identity that doesn't depend on how they look. Give one to every filter from the first release, since adding it later takes the extra steps below. EntryFilterMetadata holds:

val id: String? = null
Names the filter. Unique across all of the source's filters, including those inside groups, and never reused for something else.
val optionIds: List<String> = emptyList()
Names each choice of a Select or Sort, in the same order as values. Reorder the choices freely; the IDs move with them.
val neutralOptionId: String? = null
The choice that means no filtering, as in Active filters and clearing.
val role: EntryFilterRole = EntryFilterRole.CONSTRAINT
ORDERING for a Select that sets the order, as in Active filters and clearing.
val description: String? = null
A line of help under the filter.
val aliases: Set<String> = emptySet()
Former IDs of this filter, such as those of filters merged into it.
val optionAliases: Map<String, String> = emptyMap()
Maps former choice IDs to current ones in optionIds.
val legacyNames: Set<String> = emptySet()
The labels the filter had in releases without an id, for presets saved then.
val legacyOptionIds: List<String> = emptyList()
The IDs of the choices in the order they had in releases without an id.

Once a filter has an id, presets saved before it had one find it only through legacyNames, so a source that has already shipped filters without metadata lists each filter's old label there, even if the label hasn't changed. A Select or Sort also needs legacyOptionIds, or its saved choice is lost:

Kotlin
class StatusFilter :
    EntryFilter.Select<String>("Publication", arrayOf("Any", "Ongoing", "Completed", "On hiatus")),
    EntryFilterMetadataProvider {
    override val filterMetadata = EntryFilterMetadata(
        id = "status",
        optionIds = listOf("any", "ongoing", "completed", "hiatus"),
        neutralOptionId = "any",
        legacyNames = setOf("Status"),
        legacyOptionIds = listOf("any", "ongoing", "completed"),
    )
}

Katari refuses to search with a filter list whose identities are ambiguous, and shows Ambiguous filter identity with the filter's name. That happens when:

  • An id or alias is blank, or used by two filters.
  • optionIds doesn't have exactly one non-blank, distinct ID per choice, or the state points past the last choice.
  • neutralOptionId, or a target in optionAliases, isn't one of optionIds.

A text value is saved as it is. If its format changes, as with a Text, Autocomplete, EntryDateFilter or PagedGroup state, implement EntryFilterValueMigration on the filter. Values saved before it have version 1, so raise filterStateVersion to 2 and above, and convert older values in migrateFilterValue(value, previousVersion), or return null for ones that can't be converted. Katari also passes values saved at the current version, so return those unchanged. Without it, only values from version 1 restore. A preset whose value can't be restored, or fails validation once restored, asks the user for repair.

Active filters and clearing #

The search sheet shows how many filters are active and offers to clear them:

Filter Counts as active Clearing
CheckBox, TriState When on, or not ignored. Turns it off, or back to ignored.
EntryDateFilter When it holds a date. Empties it, unless it is required.
Select with neutralOptionId When another choice is picked. Goes back to the neutral choice, such as "Any".
Sort, and a Select with role = ORDERING Never. They are shown with the sort controls. Left alone.
PagedGroup Its selectedItemCount. Not offered.
Group The sum of the filters inside. Clears every filter inside, if each of them can be cleared.
Text, Autocomplete, Select without neutralOptionId Katari can't tell, and neither can a group holding one. Not offered.

For a filter Katari can't count, implement EntryFilterStateSemantics, as in Rarely needed.

Dates #

EntryDateFilter opens a date editor instead of a text field. It takes a year, a month or a full date, and needs an id in its metadata:

Kotlin
class ReleaseDate(name: String, id: String) : EntryDateFilter(
    name = name,
    filterMetadata = EntryFilterMetadata(id = id),
    allowedPrecisions = setOf(EntryDatePrecision.YEAR, EntryDatePrecision.MONTH),
    minimum = EntryPartialDate(1950),
)
initialValue: EntryPartialDate? = null
The date the filter starts with, or null for an empty one.
allowedPrecisions: Set<EntryDatePrecision> = setOf(YEAR, MONTH, DAY)
Which of the EntryDatePrecision values the user can pick. It must not be empty.
minimum: EntryPartialDate? = null
The earliest date allowed, or null for no limit.
maximum: EntryPartialDate? = null
The latest date allowed, or null for no limit.
required: Boolean = false
When true, an empty date fails validation.

Read the picked date from dateValue, an EntryPartialDate(year, month, day) whose month and day are null when the user didn't pick them, or null when it is empty. Its toString() gives 2024, 2024-05 or 2024-05-17. Years run from 1 to 9999, and EntryPartialDate.parse returns null for text that isn't such a date.

Required and related fields #

An EntryDateFilter already rejects dates outside its limits, and an empty one when required = true. For a rule across several filters, such as a range whose end must not come before its start, implement EntryFilterValidator on the group that holds them. Katari passes copies of the group's filters to validateFilter. They carry the values but aren't your own classes, so read them as EntryDateFilter, EntryFilter.Text and so on, and don't change them:

Kotlin
class ReleasedFilter :
    EntryFilter.Group<ReleaseDate>(
        "Released",
        listOf(ReleaseDate("From", "released-from"), ReleaseDate("To", "released-to")),
    ),
    EntryFilterMetadataProvider,
    EntryFilterValidator {
    override val filterMetadata = EntryFilterMetadata(id = "released")

    override fun validateFilter(values: List<EntryFilter<*>>): List<EntryFilterValidationIssue> {
        val (from, to) = values.map { EntryPartialDate.parse((it as EntryDateFilter).state) }
        if (from == null || to == null || from.earliestDayKey() <= to.latestDayKey()) return emptyList()
        return listOf(
            EntryFilterValidationIssue(setOf("released-from", "released-to"), EntryFilterValidationCode.DATE_RANGE),
        )
    }

    fun applyTo(url: HttpUrl.Builder) {
        val (from, to) = state
        from.dateValue?.let { url.addQueryParameter("from", it.toString()) }
        to.dateValue?.let { url.addQueryParameter("to", it.toString()) }
    }
}

Each EntryFilterValidationIssue(filterIds, code, message) names the IDs of the filters it is about. Katari marks those filters, lists the problems at the top of the sheet, and won't apply the filters or save a preset until they are fixed, so getSearchContent only receives valid values. Katari words the problem from its code: REQUIRED, INVALID_DATE, DATE_PRECISION, DATE_BOUNDS or DATE_RANGE. For a rule of your own, use SOURCE and give the text as message; a message given with any other code replaces Katari's wording too.

Options the site provides #

A Group lists its choices in your code. When the site has too many to list, or they change too often, let Katari ask the site instead:

  • Autocomplete is a text field that suggests values from the site as the user types, such as author names. Its state is still the plain text, so a preset never depends on a suggestion being found again.
  • PagedGroup is a list of choices that Katari loads page by page, with a search field, such as a site's thousands of tags. It opens on a screen of its own and can show what is selected separately.

An Autocomplete decides what part of the text to look up, fetches suggestions, and returns the text after the user picks one. Katari schedules the lookups and cancels those that are no longer needed:

Kotlin
class AuthorFilter : EntryFilter.Autocomplete(
    "Author",
    options = EntryFilterAutocompleteOptions(minimumQueryLength = 2),
) {
    override fun getSuggestionQuery(input: EntryFilterTextInput): String? = input.text.trim()

    override suspend fun getSuggestions(input: EntryFilterTextInput, query: String): List<EntryFilterSuggestion> =
        findAuthors(query).map { EntryFilterSuggestion(id = it.slug, label = it.name) }

    override fun applySuggestion(input: EntryFilterTextInput, suggestion: EntryFilterSuggestion) =
        EntryFilterTextEdit(suggestion.label, selectionStart = suggestion.label.length)
}
fun getSuggestionQuery(input: EntryFilterTextInput): String?
Returns the text to look up from input's text, selectionStart and selectionEnd, or null when suggestions don't apply where the cursor is. The selection is in UTF-16 indices and its ends may be reversed.
suspend fun getSuggestions(input: EntryFilterTextInput, query: String): List<EntryFilterSuggestion>
Returns EntryFilterSuggestion(id, label, value = id)s. id and label must not be empty.
fun applySuggestion(input: EntryFilterTextInput, suggestion: EntryFilterSuggestion): EntryFilterTextEdit
Returns the whole new text and cursor as EntryFilterTextEdit(text, selectionStart, selectionEnd = selectionStart), so the source keeps its own separators, quotes and other syntax.
val options: EntryFilterAutocompleteOptions
Passed to the constructor.

Fields

debounceMillis: Long = 300
How long after the last change Katari waits before asking.
minimumQueryLength: Int = 1
Shorter text isn't looked up.
requestOnFocus: Boolean = false
Ask as soon as the field is focused, before the user types.
maximumResults: Int = 20
How many suggestions are shown.

Lists loaded page by page #

A PagedGroup<S>(name, initialState, options) keeps the user's choices in one immutable state of your own type S. The items Katari loads, and the controls made from them, are only views of that state, and Katari throws them away as the user scrolls:

Kotlin
@Serializable
data class PickedTag(val label: String, val state: Int)

class TagFilter :
    EntryFilter.PagedGroup<Map<String, PickedTag>>("Tags", emptyMap()),
    EntryFilterMetadataProvider {
    override val filterMetadata = EntryFilterMetadata(id = "tags")

    override suspend fun getPage(request: EntryFilterPageRequest): EntryFilterPage {
        if (request.scope == EntryFilterPageScope.SELECTED) {
            return EntryFilterPage(state.map { (slug, tag) -> EntryFilterPageItem(slug, tag.label) })
        }
        val page = request.continuationToken?.toInt() ?: 1
        val result = findTags(request.query, page, request.requestedSize)
        return EntryFilterPage(
            items = result.tags.map { EntryFilterPageItem(id = it.slug, label = it.name) },
            nextContinuationToken = if (result.hasMore) (page + 1).toString() else null,
        )
    }

    override fun projectItem(item: EntryFilterPageItem, previous: EntryFilter<*>?): EntryFilter<*> {
        val current = state[item.id]?.state ?: EntryFilter.TriState.STATE_IGNORE
        return object : EntryFilter.TriState(item.label, current) {}
    }

    override fun reduceItemUpdate(item: EntryFilterPageItem, updatedFilter: EntryFilter<*>): Map<String, PickedTag> {
        val picked = (updatedFilter as EntryFilter.TriState).state
        return if (picked == EntryFilter.TriState.STATE_IGNORE) {
            state - item.id
        } else {
            state + (item.id to PickedTag(item.label, picked))
        }
    }

    override fun selectedItemCount(state: Map<String, PickedTag>): Int = state.size

    override fun encodeState(state: Map<String, PickedTag>): String = Json.encodeToString(state)

    override fun decodeState(value: String): Map<String, PickedTag>? =
        runCatching { Json.decodeFromString<Map<String, PickedTag>>(value) }.getOrNull()
}
suspend fun getPage(request: EntryFilterPageRequest): EntryFilterPage
Returns one page as EntryFilterPage(items, nextContinuationToken, previousContinuationToken). The request's scope is AVAILABLE for everything, or SELECTED for what the state holds; query is the user's search or null; continuationToken is the token you returned for the next or previous page, or null for the first; requestedSize is the page size. A null next token ends the list. Each item is an EntryFilterPageItem(id, label, value = id) whose id stays the same for the same item on every page.
fun projectItem(item: EntryFilterPageItem, previous: EntryFilter<*>?): EntryFilter<*>
Returns the control shown for an item, such as a TriState, set from the state. previous is the control last made for the same item while it stays visible, which can be updated and returned instead.
fun reduceItemUpdate(item: EntryFilterPageItem, updatedFilter: EntryFilter<*>): S
Returns a new state with the user's change to that control.
fun selectedItemCount(state: S): Int
How many choices the state holds, for the active count.
fun encodeState(state: S): String
Turns the state into the text a preset saves. It must give the same text for the same state.
fun decodeState(value: String): S?
Reads saved text back into a state, or returns null for text it can't read.
val options: EntryFilterPagingOptions
Passed to the constructor.

Fields

pageSize: Int = 50
At most 200.
search: EntryFilterPagingSearchOptions?
EntryFilterPagingSearchOptions(debounceMillis = 300, minimumQueryLength = 1) by default, or null for no search field.
open suspend fun getNavigation(request: EntryFilterNavigationRequest): EntryFilterNavigation
Optional. Returns EntryFilterNavigationTarget(id, label, anchor)s, such as letters of the alphabet, for jumping through a long list. Katari passes a picked target's anchor as the next request's initialAnchor, and an item's navigationTargetId tells Katari where the list has scrolled to.

Rarely needed #

  • EntryFilterGroupSummary on a Group replaces the text under it while it is collapsed: selectionSummary(values) returns the text for the filters inside, or null to keep Katari's.
  • EntryFilterStateSemantics tells Katari how to count and clear a filter whose state it can't read, such as a Text holding a whole query. activeSelectionCount(value) returns the count, or null when it can't tell; canClearSelection says whether clearing is offered; clearSelection(value) clears it. Read and change only the value Katari passes, never the filter's own state.