Added option to change how library is grouped

By default (categories)
By tag/genre
By sources (with the extension icon attached)
By status
and by tracking status (closes #249)
This commit is contained in:
Jay 2020-05-17 03:37:36 -04:00
parent 59c108a972
commit bc8ed36d1c
14 changed files with 298 additions and 73 deletions

View File

@ -28,6 +28,8 @@ interface Category : Serializable {
var isDynamic: Boolean
var sourceId: Long?
fun isAscending(): Boolean {
return ((mangaSort?.minus('a') ?: 0) % 2) != 1
}

View File

@ -20,6 +20,8 @@ class CategoryImpl : Category {
override var isDynamic: Boolean = false
override var sourceId: Long? = null
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || javaClass != other.javaClass) return false

View File

@ -273,6 +273,8 @@ class PreferencesHelper(val context: Context) {
fun hideHopper() = flowPrefs.getBoolean("hide_hopper", false)
fun groupLibraryBy() = flowPrefs.getInt("group_library_by", 0)
// Tutorial preferences
fun shownFilterTutorial() = flowPrefs.getBoolean("shown_filter_tutorial", false)

View File

@ -126,15 +126,13 @@ class LibraryCategoryAdapter(val controller: LibraryController) :
val text = if (item.manga.isBlank()) return item.header?.category?.name.orEmpty()
else when (getSort(position)) {
LibrarySort.DRAG_AND_DROP -> {
if (!preferences.hideCategories().getOrDefault()) {
val title = item.manga.title
if (preferences.removeArticles().getOrDefault()) title.removeArticles()
.chop(15)
else title.take(10)
} else {
val category = db.getCategoriesForManga(item.manga).executeAsBlocking()
.firstOrNull()?.name
if (item.header.category.isDynamic) {
val category = db.getCategoriesForManga(item.manga).executeAsBlocking().firstOrNull()?.name
category ?: recyclerView.context.getString(R.string.default_value)
} else {
val title = item.manga.title
if (preferences.removeArticles().getOrDefault()) title.removeArticles().chop(15)
else title.take(10)
}
}
LibrarySort.LAST_READ -> {

View File

@ -51,6 +51,11 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.category.ManageCategoryDialog
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_DEFAULT
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_SOURCE
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_STATUS
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TAG
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TRACK_STATUS
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
import eu.kanade.tachiyomi.ui.main.BottomSheetController
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -379,7 +384,7 @@ class LibraryController(
swipe_refresh.isRefreshing = false
if (!LibraryUpdateService.isRunning()) {
when {
!presenter.showAllCategories -> {
!presenter.showAllCategories || presenter.groupType != BY_DEFAULT -> {
presenter.categories.find { it.id == presenter.currentCategory }?.let {
updateLibrary(it)
}
@ -425,6 +430,31 @@ class LibraryController(
FilterBottomSheet.ACTION_HIDE_FILTER_TIP -> showFilterTip()
FilterBottomSheet.ACTION_DISPLAY -> DisplayBottomSheet(this).show()
FilterBottomSheet.ACTION_EXPAND_COLLAPSE_ALL -> presenter.toggleAllCategoryVisibility()
FilterBottomSheet.ACTION_GROUP_BY -> {
val groupItems = mutableListOf(BY_DEFAULT, BY_TAG, BY_SOURCE, BY_STATUS)
if (presenter.isLoggedIntoTracking) {
groupItems.add(BY_TRACK_STATUS)
}
val items = groupItems.map { id ->
MaterialMenuSheet.MenuSheetItem(
id,
LibraryGroup.groupTypeDrawableRes(id),
LibraryGroup.groupTypeStringRes(id)
)
}
MaterialMenuSheet(
activity!!,
items,
activity!!.getString(R.string.group_library_by),
presenter.groupType
) { _, item ->
preferences.groupLibraryBy().set(item)
presenter.groupType = item
recycler?.scrollToPosition(0)
presenter.getLibrary()
true
}.show()
}
}
}
@ -662,7 +692,8 @@ class LibraryController(
category_hopper_frame.visibleIf(!singleCategory && !preferences.hideHopper().get())
filter_bottom_sheet.updateButtons(
showHideCategories = presenter.allCategories.size > 1,
showExpand = !singleCategory && presenter.showAllCategories
showExpand = !singleCategory && presenter.showAllCategories,
groupType = presenter.groupType
)
adapter.isLongPressDragEnabled = canDrag()
category_recycler.setCategories(presenter.categories)

View File

@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.ui.library
import eu.kanade.tachiyomi.R
object LibraryGroup {
const val BY_DEFAULT = 0
const val BY_TAG = 1
const val BY_SOURCE = 2
const val BY_STATUS = 3
const val BY_TRACK_STATUS = 4
fun groupTypeStringRes(type: Int): Int {
return when (type) {
BY_STATUS -> R.string.status
BY_TAG -> R.string.tag
BY_TRACK_STATUS -> R.string.tracking
BY_SOURCE -> R.string.sources
else -> R.string.categories
}
}
fun groupTypeDrawableRes(type: Int): Int {
return when (type) {
BY_STATUS -> R.drawable.ic_progress_clock_24dp
BY_TAG -> R.drawable.ic_style_24dp
BY_TRACK_STATUS -> R.drawable.ic_sync_black_24dp
BY_SOURCE -> R.drawable.ic_browse_24dp
else -> R.drawable.ic_label_outline_white_24dp
}
}
}

View File

@ -19,6 +19,8 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.icon
import eu.kanade.tachiyomi.ui.base.holder.BaseFlexibleViewHolder
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.getResourceColor
@ -30,13 +32,16 @@ import eu.kanade.tachiyomi.util.view.visibleIf
import kotlinx.android.synthetic.main.library_category_header_item.*
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class LibraryHeaderItem(
private val categoryF: (Int) -> Category,
private val catId: Int
val catId: Int
) :
AbstractHeaderItem<LibraryHeaderItem.Holder>() {
private val sourceManager by injectLazy<SourceManager>()
override fun getLayoutRes(): Int {
return R.layout.library_category_header_item
}
@ -139,6 +144,13 @@ class LibraryHeaderItem(
if (category.isAlone) sectionText.text = ""
else sectionText.text = category.name
if (category.sourceId != null) {
val icon = item.sourceManager.get(category.sourceId!!)?.icon()
icon?.setBounds(0, 0, 32.dpToPx, 32.dpToPx)
sectionText.setCompoundDrawablesRelative(icon, null, null, null)
} else {
sectionText.setCompoundDrawablesRelative(null, null, null, null)
}
val isAscending = category.isAscending()
val sortingMode = category.sortingMode()

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.library
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
@ -15,6 +16,10 @@ import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_DEFAULT
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_SOURCE
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TAG
import eu.kanade.tachiyomi.ui.library.LibraryGroup.BY_TRACK_STATUS
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet.Companion.STATE_EXCLUDE
import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet.Companion.STATE_IGNORE
@ -29,7 +34,6 @@ import kotlinx.coroutines.withContext
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.ArrayList
import java.util.Collections
import java.util.Comparator
/**
@ -50,6 +54,11 @@ class LibraryPresenter(
private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
var groupType = preferences.groupLibraryBy().get()
val isLoggedIntoTracking
get() = loggedServices.isNotEmpty()
/** Current categories of the library. */
var categories: List<Category> = emptyList()
private set
@ -422,63 +431,77 @@ class LibraryPresenter(
val categories = db.getCategories().executeAsBlocking().toMutableList()
val showCategories = !preferences.hideCategories().getOrDefault()
var libraryManga = db.getLibraryMangas().executeAsBlocking()
val showAll = showAllCategories
if (!showCategories) libraryManga = libraryManga.distinctBy { it.id }
val categoryAll = Category.createAll(
context,
preferences.librarySortingMode().getOrDefault(),
preferences.librarySortingAscending().getOrDefault()
)
val catItemAll = LibraryHeaderItem({ categoryAll }, -1)
val categorySet = mutableSetOf<Int>()
val headerItems = (categories.mapNotNull { category ->
val id = category.id
if (id == null) null
else id to LibraryHeaderItem({ getCategory(id) }, id)
} + (-1 to catItemAll) + (0 to LibraryHeaderItem({ getCategory(0) }, 0))).toMap()
val items = libraryManga.mapNotNull {
val headerItem = (if (!showCategories) catItemAll
else headerItems[it.category]) ?: return@mapNotNull null
categorySet.add(it.category)
LibraryItem(it, headerItem)
}.toMutableList()
val categoriesHidden = preferences.collapsedCategories().getOrDefault().mapNotNull {
it.toIntOrNull()
}.toMutableSet()
if (categorySet.contains(0)) categories.add(0, createDefaultCategory())
if (showCategories) {
categories.forEach { category ->
val catId = category.id ?: return@forEach
if (catId > 0 && !categorySet.contains(catId) &&
(catId !in categoriesHidden || !showAll)) {
val headerItem = headerItems[catId]
if (headerItem != null) items.add(
LibraryItem(LibraryManga.createBlank(catId), headerItem)
)
} else if (catId in categoriesHidden && showAll && categories.size > 1) {
val mangaToRemove = items.filter { it.manga.category == catId }
val mergedTitle = mangaToRemove.joinToString("-") {
it.manga.title + "-" + it.manga.author
}
sectionedLibraryItems[catId] = mangaToRemove
items.removeAll(mangaToRemove)
val headerItem = headerItems[catId]
if (headerItem != null) items.add(
LibraryItem(LibraryManga.createHide(catId, mergedTitle), headerItem)
)
}
}
if (groupType <= BY_DEFAULT || !showCategories) {
libraryManga = libraryManga.distinctBy { it.id }
}
categories.forEach {
it.isHidden = it.id in categoriesHidden && showAll
val items = if (groupType <= BY_DEFAULT || !showCategories) {
val categoryAll = Category.createAll(
context,
preferences.librarySortingMode().getOrDefault(),
preferences.librarySortingAscending().getOrDefault()
)
val catItemAll = LibraryHeaderItem({ categoryAll }, -1)
val categorySet = mutableSetOf<Int>()
val headerItems = (categories.mapNotNull { category ->
val id = category.id
if (id == null) null
else id to LibraryHeaderItem({ getCategory(id) }, id)
} + (-1 to catItemAll) + (0 to LibraryHeaderItem({ getCategory(0) }, 0))).toMap()
val items = libraryManga.mapNotNull {
val headerItem = (if (!showCategories) catItemAll
else headerItems[it.category]) ?: return@mapNotNull null
categorySet.add(it.category)
LibraryItem(it, headerItem)
}.toMutableList()
val categoriesHidden = preferences.collapsedCategories().getOrDefault().mapNotNull {
it.toIntOrNull()
}.toMutableSet()
if (categorySet.contains(0)) categories.add(0, createDefaultCategory())
if (showCategories) {
categories.forEach { category ->
val catId = category.id ?: return@forEach
if (catId > 0 && !categorySet.contains(catId) && (catId !in categoriesHidden ||
!showCategories)) {
val headerItem = headerItems[catId]
if (headerItem != null) items.add(
LibraryItem(LibraryManga.createBlank(catId), headerItem)
)
} else if (catId in categoriesHidden && showCategories && categories.size > 1) {
val mangaToRemove = items.filter { it.manga.category == catId }
val mergedTitle = mangaToRemove.joinToString("-") {
it.manga.title + "-" + it.manga.author
}
sectionedLibraryItems[catId] = mangaToRemove
items.removeAll(mangaToRemove)
val headerItem = headerItems[catId]
if (headerItem != null) items.add(
LibraryItem(LibraryManga.createHide(catId, mergedTitle), headerItem)
)
}
}
}
categories.forEach {
it.isHidden = it.id in categoriesHidden && showCategories
}
this.categories = if (!showCategories) {
arrayListOf(categoryAll)
} else {
categories
}
items
} else {
val (items, categories) = getCustomMangaItems(libraryManga)
this.categories = categories
items
}
this.allCategories = categories
this.categories = if (!showCategories) arrayListOf(categoryAll)
else categories
hashCategories = HashMap(this.categories.mapNotNull {
it.id!! to it
@ -487,6 +510,86 @@ class LibraryPresenter(
return items
}
private fun getCustomMangaItems(libraryManga: List<LibraryManga>): Pair<List<LibraryItem>,
List<Category>> {
val tagItems: MutableMap<String, LibraryHeaderItem> = mutableMapOf()
// internal function to make headers
fun makeOrGetHeader(name: String): LibraryHeaderItem {
return if (tagItems.containsKey(name)) {
tagItems[name]!!
} else {
val headerItem = LibraryHeaderItem({ getCategory(it) }, tagItems.count())
tagItems[name] = headerItem
headerItem
}
}
val items = libraryManga.mapNotNull { manga ->
when (groupType) {
BY_TAG -> {
val tags = if (manga.genre.isNullOrBlank()) {
listOf("Unknown")
} else {
manga.genre?.split(",")?.mapNotNull {
val tag = it.trim()
if (tag.isBlank()) null else tag
} ?: listOf("Unknown")
}
tags.map {
LibraryItem(manga, makeOrGetHeader(it))
}
}
BY_TRACK_STATUS -> {
val status: String = {
val tracks = db.getTracks(manga).executeAsBlocking()
val track = tracks.find { track ->
loggedServices.any { it.id == track?.sync_id }
}
if (track != null) {
loggedServices.find { it.id == track.sync_id }?.getStatus(track.status)
?: context.getString(R.string.unknown)
} else {
context.getString(R.string.not_tracked)
}
}()
listOf(LibraryItem(manga, makeOrGetHeader(status)))
}
BY_SOURCE -> {
val source = sourceManager.getOrStub(manga.source)
listOf(LibraryItem(manga, makeOrGetHeader("${source.name}◘•◘${source.id}")))
}
else -> listOf(LibraryItem(manga, makeOrGetHeader(mapStatus(manga.status))))
}
}.flatten()
val headers = tagItems.map { item ->
Category.createCustom(
item.key,
preferences.librarySortingMode().getOrDefault(),
preferences.librarySortingAscending().getOrDefault()
).apply {
id = item.value.catId
if (name.contains("◘•◘")) {
val split = name.split("◘•◘")
name = split.first()
sourceId = split.last().toLongOrNull()
}
}
}.sortedBy { it.name }
headers.forEachIndexed { index, category -> category.order = index }
return items to headers
}
private fun mapStatus(status: Int): String {
return context.getString(when (status) {
SManga.LICENSED -> R.string.licensed
SManga.ONGOING -> R.string.ongoing
SManga.COMPLETED -> R.string.completed
else -> R.string.unknown
})
}
/** Create a default category with the sort set */
private fun createDefaultCategory(): Category {
val default = Category.createDefault(context)
@ -632,6 +735,9 @@ class LibraryPresenter(
val sort = category.sortingMode() ?: LibrarySort.ALPHA
preferences.librarySortingMode().set(sort)
preferences.librarySortingAscending().set(category.isAscending())
categories.forEach {
it.mangaSort = category.mangaSort
}
} else if (catId >= 0) {
if (category.id == 0) preferences.defaultMangaOrder().set(category.mangaSort.toString())
else Injekt.get<DatabaseHelper>().insertCategory(category).executeAsBlocking()

View File

@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.library.LibraryGroup
import eu.kanade.tachiyomi.util.system.launchUI
import eu.kanade.tachiyomi.util.view.collapse
import eu.kanade.tachiyomi.util.view.gone
@ -126,6 +127,9 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri
expand_categories.setOnClickListener {
onGroupClicked(ACTION_EXPAND_COLLAPSE_ALL)
}
group_by.setOnClickListener {
onGroupClicked(ACTION_GROUP_BY)
}
view_options.setOnClickListener {
onGroupClicked(ACTION_DISPLAY)
}
@ -413,8 +417,14 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri
}?.set(index + 1)
onGroupClicked(ACTION_FILTER)
}
val hasFilters = hasActiveFilters()
if (hasFilters && clearButton.parent == null) {
filter_layout.addView(clearButton, 0)
} else if (!hasFilters && clearButton.parent != null) {
filter_layout.removeView(clearButton)
}
if (tracked?.isActivated == true && trackers != null && trackers?.parent == null) {
filter_layout.addView(trackers, filterItems.indexOf(tracked!!) + 1)
filter_layout.addView(trackers, filterItems.indexOf(tracked!!) + 2)
filterItems.add(filterItems.indexOf(tracked!!) + 1, trackers!!)
} else if (tracked?.isActivated == false && trackers?.parent != null) {
filter_layout.removeView(trackers)
@ -422,20 +432,15 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri
FILTER_TRACKER = ""
filterItems.remove(trackers!!)
}
val hasFilters = hasActiveFilters()
if (hasFilters && clearButton.parent == null) {
filter_layout.addView(clearButton, 0)
} else if (!hasFilters && clearButton.parent != null) {
filter_layout.removeView(clearButton)
}
}
fun updateButtons(showHideCategories: Boolean, showExpand: Boolean) {
fun updateButtons(showHideCategories: Boolean, showExpand: Boolean, groupType: Int) {
hide_categories.visibleIf(showHideCategories)
expand_categories.visibleIf(showExpand)
expand_categories.visibleIf(showExpand && groupType == 0)
first_layout.visibleIf(
hide_categories.isVisible() || expand_categories.isVisible() || !second_layout.isVisible()
)
group_by.setIconResource(LibraryGroup.groupTypeDrawableRes(groupType))
}
private fun clearFilters() {
@ -479,6 +484,7 @@ class FilterBottomSheet @JvmOverloads constructor(context: Context, attrs: Attri
const val ACTION_HIDE_FILTER_TIP = 2
const val ACTION_DISPLAY = 3
const val ACTION_EXPAND_COLLAPSE_ALL = 4
const val ACTION_GROUP_BY = 5
const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1

View File

@ -0,0 +1,10 @@
<!-- drawable/progress_clock.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M13,2.03V2.05L13,4.05C17.39,4.59 20.5,8.58 19.96,12.97C19.5,16.61 16.64,19.5 13,19.93V21.93C18.5,21.38 22.5,16.5 21.95,11C21.5,6.25 17.73,2.5 13,2.03M11,2.06C9.05,2.25 7.19,3 5.67,4.26L7.1,5.74C8.22,4.84 9.57,4.26 11,4.06V2.06M4.26,5.67C3,7.19 2.25,9.04 2.05,11H4.05C4.24,9.58 4.8,8.23 5.69,7.1L4.26,5.67M2.06,13C2.26,14.96 3.03,16.81 4.27,18.33L5.69,16.9C4.81,15.77 4.24,14.42 4.06,13H2.06M7.1,18.37L5.67,19.74C7.18,21 9.04,21.79 11,22V20C9.58,19.82 8.23,19.25 7.1,18.37M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#000"
android:pathData="M2.53,19.65l1.34,0.56v-9.03l-2.43,5.86c-0.41,1.02 0.08,2.19 1.09,2.61zM22.03,15.95L17.07,3.98c-0.31,-0.75 -1.04,-1.21 -1.81,-1.23 -0.26,0 -0.53,0.04 -0.79,0.15L7.1,5.95c-0.75,0.31 -1.21,1.03 -1.23,1.8 -0.01,0.27 0.04,0.54 0.15,0.8l4.96,11.97c0.31,0.76 1.05,1.22 1.83,1.23 0.26,0 0.52,-0.05 0.77,-0.15l7.36,-3.05c1.02,-0.42 1.51,-1.59 1.09,-2.6zM7.88,8.75c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1 1,0.45 1,1 -0.45,1 -1,1zM5.88,19.75c0,1.1 0.9,2 2,2h1.45l-3.45,-8.34v6.34z" />
</vector>

View File

@ -89,6 +89,18 @@
app:iconTint="?android:attr/textColorPrimary" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/group_by"
style="@style/Theme.Widget.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:text="@string/group_library_by"
android:textColor="?android:attr/textColorPrimary"
app:icon="@drawable/ic_label_outline_white_24dp"
app:iconTint="?android:attr/textColorPrimary" />
<LinearLayout
android:id="@+id/second_layout"
android:layout_width="wrap_content"

View File

@ -70,6 +70,7 @@
android:layout_marginTop="28dp"
android:layout_marginBottom="6dp"
android:background="@drawable/square_ripple"
android:drawablePadding="6dp"
android:ellipsize="end"
android:gravity="center|start"
android:inputType="none"

View File

@ -123,6 +123,7 @@
<string name="read_progress">Read progress</string>
<string name="series_type">Series type</string>
<string name="group_library_by">Group library by…</string>
<!-- Library Sort -->
<string name="sort_by">Sort by</string>
@ -713,6 +714,7 @@
<string name="sort_and_filter">Sort &amp; Filter</string>
<string name="start">Start</string>
<string name="stop">Stop</string>
<string name="tag">Tag</string>
<string name="top">Top</string>
<string name="undo">Undo</string>
<string name="unknown_error">Unknown error</string>