From e5a8dc98d672233993a8ba12f8eca2dc28d266e6 Mon Sep 17 00:00:00 2001
From: damontecres <154766448+damontecres@users.noreply.github.com>
Date: Fri, 20 Sep 2024 19:55:19 -0400
Subject: [PATCH] Create new filters for Scenes in the app (#420)
Related to #178
Adds ability to create a filter for scenes and optionally &
experimentally save it to the server. Can start with a blank filter or
build up from the currently selected saved filter.
Note: saving to the server requires enabling experiment features in
advanced settings since it is not fully tested
This has several limitations:
- Only supports scene filters right now
- Only a limited subset of available filter options
- UI/UX is okay, but not great
- It takes many clicks to configure things
- Lots of hand written code AKA possibly error prone
---
app/src/main/AndroidManifest.xml | 4 +
app/src/main/graphql/FindMovies.graphql | 4 +-
app/src/main/graphql/FindSavedFilter.graphql | 6 +
.../stashapp/FilterListActivity.kt | 158 +++++--
.../damontecres/stashapp/data/DataType.kt | 23 ++
.../stashapp/data/StashFindFilter.kt | 15 +
.../stashapp/filter/CreateFilterActivity.kt | 38 ++
.../filter/CreateFilterGuidedStepFragment.kt | 95 +++++
.../stashapp/filter/CreateFilterStep.kt | 188 +++++++++
.../stashapp/filter/CreateFilterViewModel.kt | 118 ++++++
.../filter/CreateFindFilterFragment.kt | 164 ++++++++
.../stashapp/filter/CreateObjectFilterStep.kt | 163 ++++++++
.../stashapp/filter/DescriptionExtractors.kt | 389 ++++++++++++++++++
.../stashapp/filter/FilterOption.kt | 180 ++++++++
.../stashapp/filter/output/FilterOutputs.kt | 221 ++++++++++
.../stashapp/filter/output/FilterWriter.kt | 103 +++++
.../filter/picker/BooleanPickerFragment.kt | 77 ++++
.../filter/picker/DatePickerFragment.kt | 136 ++++++
.../filter/picker/FloatPickerFragment.kt | 142 +++++++
.../HierarchicalMultiCriterionFragment.kt | 177 ++++++++
.../filter/picker/IntPickerFragment.kt | 133 ++++++
.../filter/picker/MultiCriterionFragment.kt | 163 ++++++++
.../filter/picker/RatingPickerFragment.kt | 180 ++++++++
.../filter/picker/SearchPickerFragment.kt | 293 +++++++++++++
.../filter/picker/StringPickerFragment.kt | 111 +++++
.../stashapp/suppliers/MovieDataSupplier.kt | 1 +
.../damontecres/stashapp/util/Constants.kt | 24 +-
.../stashapp/util/MutationEngine.kt | 8 +
.../damontecres/stashapp/util/QueryEngine.kt | 28 +-
.../damontecres/stashapp/views/Formatting.kt | 21 +
app/src/main/res/layout/popup_header.xml | 17 +
.../stashapp/FrontPageFilterTests.kt | 9 +-
32 files changed, 3355 insertions(+), 34 deletions(-)
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterActivity.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterGuidedStepFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterStep.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/CreateFindFilterFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/CreateObjectFilterStep.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/DescriptionExtractors.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/FilterOption.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterOutputs.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterWriter.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/BooleanPickerFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/DatePickerFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/FloatPickerFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/HierarchicalMultiCriterionFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/IntPickerFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/MultiCriterionFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/RatingPickerFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/SearchPickerFragment.kt
create mode 100644 app/src/main/java/com/github/damontecres/stashapp/filter/picker/StringPickerFragment.kt
create mode 100644 app/src/main/res/layout/popup_header.xml
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 28a9c7d9..5f800466 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -104,6 +104,10 @@
android:name=".UpdateChangelogActivity"
android:exported="false"
android:theme="@style/NoTitleTheme" />
+
, view: View, position: Int, id: Long ->
+ Log.v(TAG, "filter list clicked position=$position")
listPopUp.dismiss()
- val savedFilter =
- savedFilters[position]
- try {
- val filterArgs =
- savedFilter
- .toFilterArgs(filterParser)
- .withResolvedRandom()
- setup(filterArgs)
- } catch (ex: Exception) {
- Log.e(TAG, "Exception parsing filter ${savedFilter.id}", ex)
- Toast.makeText(
- this@FilterListActivity,
- "Error with filter ${savedFilter.id}! Probably a bug: ${ex.message}",
- Toast.LENGTH_LONG,
- ).show()
+ val lookupPos =
+ if (adapter.createEnabled) {
+ position - 3
+ } else {
+ position
+ }
+ if (adapter.createEnabled && (position == 0 || position == 1)) {
+ val intent =
+ Intent(this@FilterListActivity, CreateFilterActivity::class.java)
+ .putDataType(dataType)
+ if (position == 1) {
+ val fragment =
+ supportFragmentManager.findFragmentById(R.id.list_fragment) as StashGridFragment
+ val filter = fragment.filterArgs
+ intent.putFilterArgs(CreateFilterActivity.INTENT_STARTING_FILTER, filter)
+ }
+ this@FilterListActivity.startActivity(intent)
+ } else {
+ val savedFilter =
+ savedFilters[lookupPos]
+ try {
+ val filterArgs =
+ savedFilter
+ .toFilterArgs(filterParser)
+ .withResolvedRandom()
+ setup(filterArgs)
+ } catch (ex: Exception) {
+ Log.e(TAG, "Exception parsing filter ${savedFilter.id}", ex)
+ Toast.makeText(
+ this@FilterListActivity,
+ "Error with filter ${savedFilter.id}! Probably a bug: ${ex.message}",
+ Toast.LENGTH_LONG,
+ ).show()
+ }
}
}
@@ -203,6 +232,89 @@ class FilterListActivity : FragmentActivity(R.layout.filter_list) {
sortButtonManager.setUpSortButton(sortButton, filter.dataType, filter.sortAndDirection)
}
+ private class SavedFilterAdapter(
+ context: Context,
+ val createEnabled: Boolean,
+ val filters: List,
+ ) : BaseAdapter() {
+ private val inflater = LayoutInflater.from(context)
+
+ override fun getCount(): Int {
+ return if (createEnabled) {
+ filters.size + 3
+ } else {
+ filters.size
+ }
+ }
+
+ override fun getItem(position: Int): Any {
+ if (createEnabled) {
+ return when (position) {
+ 0 -> "Create filter"
+ 1 -> "Create filter from current"
+ 2 ->
+ if (filters.isEmpty()) {
+ "No saved filters"
+ } else {
+ "Saved filters"
+ }
+
+ else -> filters[position - 3]
+ }
+ }
+ return filters[position]
+ }
+
+ override fun getItemId(position: Int): Long {
+ return position.toLong()
+ }
+
+ override fun getView(
+ position: Int,
+ convertView: View?,
+ parent: ViewGroup?,
+ ): View {
+ return if (convertView != null) {
+ (convertView as TextView).text = getItem(position).toString()
+ convertView
+ } else if (createEnabled && position == 2) {
+ // header
+ val view = inflater.inflate(R.layout.popup_header, parent, false) as TextView
+ view.text = getItem(position).toString()
+ view
+ } else {
+ // regular item
+ val view = inflater.inflate(R.layout.popup_item, parent, false) as TextView
+ view.text = getItem(position).toString()
+ view
+ }
+ }
+
+ override fun areAllItemsEnabled(): Boolean {
+ return !createEnabled
+ }
+
+ override fun isEnabled(position: Int): Boolean {
+ return !(createEnabled && position == 2)
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ return if (isEnabled(position)) {
+ 0
+ } else {
+ 1
+ }
+ }
+
+ override fun getViewTypeCount(): Int {
+ return if (createEnabled) {
+ 2
+ } else {
+ 1
+ }
+ }
+ }
+
companion object {
private const val TAG = "FilterListActivity2"
const val INTENT_FILTER_ARGS = "$TAG.filterArgs"
diff --git a/app/src/main/java/com/github/damontecres/stashapp/data/DataType.kt b/app/src/main/java/com/github/damontecres/stashapp/data/DataType.kt
index c7d13d68..c4fbafb0 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/data/DataType.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/data/DataType.kt
@@ -3,7 +3,16 @@ package com.github.damontecres.stashapp.data
import androidx.annotation.StringRes
import com.github.damontecres.stashapp.R
import com.github.damontecres.stashapp.api.type.FilterMode
+import com.github.damontecres.stashapp.api.type.GalleryFilterType
+import com.github.damontecres.stashapp.api.type.ImageFilterType
+import com.github.damontecres.stashapp.api.type.MovieFilterType
+import com.github.damontecres.stashapp.api.type.PerformerFilterType
+import com.github.damontecres.stashapp.api.type.SceneFilterType
+import com.github.damontecres.stashapp.api.type.SceneMarkerFilterType
import com.github.damontecres.stashapp.api.type.SortDirectionEnum
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.api.type.StudioFilterType
+import com.github.damontecres.stashapp.api.type.TagFilterType
import com.github.damontecres.stashapp.presenters.GalleryPresenter
import com.github.damontecres.stashapp.presenters.ImagePresenter
import com.github.damontecres.stashapp.presenters.MarkerPresenter
@@ -12,6 +21,7 @@ import com.github.damontecres.stashapp.presenters.PerformerPresenter
import com.github.damontecres.stashapp.presenters.ScenePresenter
import com.github.damontecres.stashapp.presenters.StudioPresenter
import com.github.damontecres.stashapp.presenters.TagPresenter
+import kotlin.reflect.KClass
enum class DataType(
val filterMode: FilterMode,
@@ -102,6 +112,19 @@ enum class DataType(
GALLERY -> GalleryPresenter.CARD_WIDTH
}
+ val filterType
+ get() =
+ when (this) {
+ SCENE -> SceneFilterType::class
+ MOVIE -> MovieFilterType::class
+ MARKER -> SceneMarkerFilterType::class
+ PERFORMER -> PerformerFilterType::class
+ STUDIO -> StudioFilterType::class
+ TAG -> TagFilterType::class
+ IMAGE -> ImageFilterType::class
+ GALLERY -> GalleryFilterType::class
+ } as KClass
+
val defaultCardRatio
get() =
ScenePresenter.CARD_WIDTH.toDouble() / defaultCardWidth
diff --git a/app/src/main/java/com/github/damontecres/stashapp/data/StashFindFilter.kt b/app/src/main/java/com/github/damontecres/stashapp/data/StashFindFilter.kt
index 1cc294a3..a04b1d6c 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/data/StashFindFilter.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/data/StashFindFilter.kt
@@ -23,6 +23,21 @@ data class StashFindFilter(
page = Optional.presentIfNotNull(page),
per_page = Optional.presentIfNotNull(perPage),
)
+
+ fun withSort(sort: String): StashFindFilter {
+ val newSortAndDirection =
+ sortAndDirection?.copy(sort = sort) ?: SortAndDirection(sort, SortDirectionEnum.ASC)
+ return this.copy(sortAndDirection = newSortAndDirection)
+ }
+
+ fun withDirection(
+ direction: SortDirectionEnum,
+ dataType: DataType,
+ ): StashFindFilter {
+ val newSortAndDirection =
+ (sortAndDirection ?: dataType.defaultSort).copy(direction = direction)
+ return this.copy(sortAndDirection = newSortAndDirection)
+ }
}
fun FindFilterType.toStashFindFilter(): StashFindFilter =
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterActivity.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterActivity.kt
new file mode 100644
index 00000000..11f6e673
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterActivity.kt
@@ -0,0 +1,38 @@
+package com.github.damontecres.stashapp.filter
+
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.fragment.app.FragmentActivity
+import androidx.leanback.app.GuidedStepSupportFragment
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.util.getDataType
+import com.github.damontecres.stashapp.util.getFilterArgs
+
+class CreateFilterActivity : FragmentActivity(R.layout.frame_layout) {
+ private val viewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (savedInstanceState == null) {
+ val dataType = intent.getDataType()
+ val startingFilter = intent.getFilterArgs(INTENT_STARTING_FILTER)
+ viewModel.initialize(
+ dataType,
+ startingFilter?.objectFilter,
+ startingFilter?.findFilter,
+ ) {
+ // This occurs after the items are loaded for labels
+ GuidedStepSupportFragment.addAsRoot(
+ this,
+ CreateFilterStep(),
+ android.R.id.content,
+ )
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "CreateFilterActivity"
+ const val INTENT_STARTING_FILTER = "$TAG.startingFilter"
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterGuidedStepFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterGuidedStepFragment.kt
new file mode 100644
index 00000000..acdc76bb
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterGuidedStepFragment.kt
@@ -0,0 +1,95 @@
+package com.github.damontecres.stashapp.filter
+
+import androidx.fragment.app.activityViewModels
+import androidx.leanback.app.GuidedStepSupportFragment
+import androidx.leanback.widget.GuidedAction
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.views.getString
+
+open class CreateFilterGuidedStepFragment : GuidedStepSupportFragment() {
+ protected val viewModel by activityViewModels()
+
+ fun nextStep(step: GuidedStepSupportFragment) {
+ add(requireActivity().supportFragmentManager, step, android.R.id.content)
+ }
+
+ override fun onProvideTheme(): Int {
+ return com.github.damontecres.stashapp.R.style.Theme_StashAppAndroidTV_GuidedStep
+ }
+
+ /**
+ * Create a [GuidedAction] for a [CriterionModifier]
+ */
+ protected fun modifierAction(modifier: CriterionModifier): GuidedAction {
+ return GuidedAction.Builder(requireContext())
+ .id(MODIFIER_OFFSET + modifier.ordinal)
+ .hasNext(false)
+ .title(modifier.getString(requireContext()))
+ .build()
+ }
+
+ /**
+ * Enable or disable the "finish" [GuidedAction].
+ *
+ * The step must define a [GuidedAction] with ID=[GuidedAction.ACTION_ID_FINISH] or this will throw an exception.
+ */
+ protected fun enableFinish(enabled: Boolean) {
+ findActionById(GuidedAction.ACTION_ID_FINISH).isEnabled = enabled
+ notifyActionChanged(findActionPositionById(GuidedAction.ACTION_ID_FINISH))
+ }
+
+ /**
+ * Handle cancel & remove actions
+ */
+ protected fun onStandardActionClicked(
+ action: GuidedAction,
+ filterOption: FilterOption,
+ ) {
+ if (action.id == GuidedAction.ACTION_ID_CANCEL) {
+ parentFragmentManager.popBackStack()
+ } else if (action.id == ACTION_ID_REMOVE) {
+ viewModel.updateFilter(filterOption, null)
+ parentFragmentManager.popBackStack()
+ }
+ }
+
+ /**
+ * Add save, remove, & cancel actions
+ */
+ protected fun addStandardActions(
+ actions: MutableList,
+ filterOption: FilterOption,
+ ) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_FINISH)
+ .hasNext(true)
+ .title(getString(com.github.damontecres.stashapp.R.string.stashapp_actions_save))
+ .build(),
+ )
+
+ if (viewModel.getValue(filterOption) != null) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(ACTION_ID_REMOVE)
+ .hasNext(true)
+ .title(getString(com.github.damontecres.stashapp.R.string.stashapp_actions_remove))
+ .build(),
+ )
+ }
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_CANCEL)
+ .hasNext(true)
+ .title(getString(com.github.damontecres.stashapp.R.string.stashapp_actions_cancel))
+ .build(),
+ )
+ }
+
+ companion object {
+ const val MODIFIER_OFFSET = 3_000_000L
+ const val ACTION_ID_REMOVE = -234L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterStep.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterStep.kt
new file mode 100644
index 00000000..1171fb0a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterStep.kt
@@ -0,0 +1,188 @@
+package com.github.damontecres.stashapp.filter
+
+import android.content.Intent
+import android.os.Bundle
+import android.text.InputType
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import androidx.lifecycle.lifecycleScope
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.FilterListActivity
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.SaveFilterInput
+import com.github.damontecres.stashapp.data.StashFindFilter
+import com.github.damontecres.stashapp.filter.output.FilterWriter
+import com.github.damontecres.stashapp.suppliers.FilterArgs
+import com.github.damontecres.stashapp.util.MutationEngine
+import com.github.damontecres.stashapp.util.QueryEngine
+import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler
+import com.github.damontecres.stashapp.util.StashServer
+import com.github.damontecres.stashapp.util.experimentalFeaturesEnabled
+import com.github.damontecres.stashapp.util.isNotNullOrBlank
+import com.github.damontecres.stashapp.util.putDataType
+import com.github.damontecres.stashapp.util.putFilterArgs
+import kotlinx.coroutines.launch
+
+/**
+ * The first step to create a new filter
+ *
+ * Assumes that [CreateFilterViewModel.initialize] has been called already
+ */
+class CreateFilterStep : CreateFilterGuidedStepFragment() {
+ private lateinit var queryEngine: QueryEngine
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ queryEngine = QueryEngine(StashServer.requireCurrentServer())
+ }
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ val text =
+ filterSummary(
+ viewModel.dataType.value!!,
+ viewModel.dataType.value!!.filterType,
+ viewModel.objectFilter.value!!,
+ viewModel::lookupIds,
+ ).ifBlank { "No filters set" }
+ return GuidanceStylist.Guidance(
+ "Create filter",
+ text,
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(FILTER_NAME)
+ .hasNext(false)
+ .title(getString(R.string.stashapp_filter_name))
+ .descriptionEditInputType(InputType.TYPE_CLASS_TEXT)
+ .descriptionEditable(true)
+ .build(),
+ )
+
+ val sortDesc =
+ findFilterSummary(
+ requireContext(),
+ viewModel.dataType.value!!,
+ viewModel.findFilter.value!!,
+ )
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(SORT_OPTION)
+ .hasNext(true)
+ .title(getString(R.string.sort_by))
+ .description(sortDesc)
+ .build(),
+ )
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(FILTER_OPTIONS)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_filters))
+ .build(),
+ )
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(SUBMIT)
+ .hasNext(true)
+ .title("Submit without saving")
+ .build(),
+ )
+ if (experimentalFeaturesEnabled()) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(SAVE_SUBMIT)
+ .hasNext(true)
+ .enabled(false)
+ .title("Save and submit")
+ .description("Experimental!")
+ .build(),
+ )
+ }
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ val dataType = viewModel.dataType.value!!
+ if (action.id == FILTER_OPTIONS) {
+ nextStep(CreateObjectFilterStep())
+ } else if (action.id == SORT_OPTION) {
+ nextStep(CreateFindFilterFragment(dataType, viewModel.findFilter.value!!))
+ } else if (action.id == SUBMIT || action.id == SAVE_SUBMIT) {
+ // Ready to load the filter!
+ val filterNameAction = findActionById(FILTER_NAME)
+ val objectFilter = viewModel.objectFilter.value!!
+ val filterArgs =
+ FilterArgs(
+ dataType = dataType,
+ name = filterNameAction.description?.toString()?.ifBlank { null },
+ findFilter = viewModel.findFilter.value,
+ objectFilter = objectFilter,
+ )
+ viewLifecycleOwner.lifecycleScope.launch(StashCoroutineExceptionHandler(autoToast = true)) {
+ // If there is a name, try to save it to the server
+ if (experimentalFeaturesEnabled() &&
+ action.id == SAVE_SUBMIT && filterArgs.name.isNotNullOrBlank()
+ ) {
+ // Save it
+ val filterWriter = FilterWriter(QueryEngine(StashServer.requireCurrentServer()))
+ val findFilter =
+ filterArgs.findFilter ?: StashFindFilter(
+ null,
+ filterArgs.dataType.defaultSort,
+ )
+ val objectFilterMap = filterWriter.convertFilter(objectFilter)
+ val mutationEngine = MutationEngine(StashServer.requireCurrentServer())
+ val newSavedFilter =
+ mutationEngine.saveFilter(
+ SaveFilterInput(
+ mode = dataType.filterMode,
+ name = filterNameAction.description.toString(),
+ find_filter =
+ Optional.presentIfNotNull(
+ findFilter.toFindFilterType(1, 40),
+ ),
+ object_filter = Optional.presentIfNotNull(objectFilterMap),
+ ui_options = Optional.absent(),
+ ),
+ )
+ Log.i(TAG, "New SavedFilter: ${newSavedFilter.id}")
+ }
+ // Finish & start the filter list activity
+ finishGuidedStepSupportFragments()
+ val intent =
+ Intent(requireContext(), FilterListActivity::class.java)
+ .putDataType(filterArgs.dataType)
+ .putFilterArgs(FilterListActivity.INTENT_FILTER_ARGS, filterArgs)
+ requireContext().startActivity(intent)
+ }
+ }
+ }
+
+ override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long {
+ if (action.id == FILTER_NAME) {
+ val submitAction = findActionById(SAVE_SUBMIT)
+ submitAction.isEnabled = action.description.isNotNullOrBlank()
+ notifyActionChanged(findActionPositionById(SAVE_SUBMIT))
+ }
+ return GuidedAction.ACTION_ID_NEXT
+ }
+
+ companion object {
+ private val TAG = CreateFilterStep::class.simpleName
+
+ private const val SAVE_SUBMIT = -1L
+ private const val SUBMIT = -2L
+ private const val FILTER_NAME = -3L
+ private const val FILTER_OPTIONS = -4L
+ private const val SORT_OPTION = -5L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt
new file mode 100644
index 00000000..45827165
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFilterViewModel.kt
@@ -0,0 +1,118 @@
+package com.github.damontecres.stashapp.filter
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.api.fragment.StashData
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.data.StashFindFilter
+import com.github.damontecres.stashapp.util.QueryEngine
+import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler
+import com.github.damontecres.stashapp.util.StashServer
+import kotlinx.coroutines.launch
+import kotlin.reflect.cast
+import kotlin.reflect.full.createInstance
+
+/**
+ * Tracks state while the user builds a new filter
+ */
+class CreateFilterViewModel : ViewModel() {
+ val server = MutableLiveData(StashServer.requireCurrentServer())
+ val queryEngine = QueryEngine(server.value!!)
+
+ val dataType = MutableLiveData()
+ val objectFilter = MutableLiveData()
+ val findFilter = MutableLiveData()
+
+ val storedItems = mutableMapOf()
+
+ /**
+ * Initialize the state
+ */
+ fun initialize(
+ dataType: DataType,
+ initialFilter: StashDataFilter?,
+ initialFindFilter: StashFindFilter?,
+ callback: () -> Unit,
+ ) {
+ this.dataType.value = dataType
+ this.objectFilter.value = initialFilter ?: dataType.filterType.createInstance()
+ this.findFilter.value =
+ initialFindFilter ?: StashFindFilter(sortAndDirection = dataType.defaultSort)
+
+ // Fetch all of the labels for any existing IDs in the initial object filter
+ viewModelScope.launch(StashCoroutineExceptionHandler()) {
+ getIdsByDataType(dataType.filterType, objectFilter.value!!).entries.forEach {
+ val dt = it.key
+ val ids = it.value
+ val items = queryEngine.getByIds(dt, ids)
+ items.forEach { item ->
+ storedItems[DataTypeId(dt, item.id)] = NameDescription(item)
+ }
+ }
+ callback()
+ }
+ }
+
+ /**
+ * Update the object filter with the new sub-value
+ */
+ fun updateFilter(
+ filterOption: FilterOption,
+ newItem: ValueType?,
+ ) {
+ val currFilter = objectFilter.value!!
+ val newFilter =
+ filterOption.setter(
+ dataType.value!!.filterType.cast(currFilter),
+ Optional.presentIfNotNull(newItem),
+ )
+ objectFilter.value = newFilter
+ }
+
+ /**
+ * Get the sub-value for the current object filter
+ */
+ fun getValue(filterOption: FilterOption): ValueType? {
+ val currFilter = objectFilter.value!!
+ val value = filterOption.getter(dataType.value!!.filterType.cast(currFilter))
+ return value.getOrNull()
+ }
+
+ /**
+ * Store an item's name & description for label purposes
+ */
+ fun store(
+ dataType: DataType,
+ item: StashData,
+ ) {
+ storedItems[DataTypeId(dataType, item.id)] = NameDescription((item))
+ }
+
+ /**
+ * Get all of the name & descriptions for a list of IDs and [DataType]
+ */
+ fun lookupIds(
+ dataType: DataType,
+ ids: List,
+ ): Map {
+ return ids.associateWith { id ->
+ val key = DataTypeId(dataType, id)
+ storedItems[key]
+ }
+ }
+
+ /**
+ * A composite of [DataType] and ID because IDs can be reused between data types
+ */
+ data class DataTypeId(val dataType: DataType, val id: String)
+
+ /**
+ * A name (or title) and description of a [StashData] item
+ */
+ data class NameDescription(val name: String?, val description: String?) {
+ constructor(item: StashData) : this(extractTitle(item), extractDescription(item))
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFindFilterFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFindFilterFragment.kt
new file mode 100644
index 00000000..ab892601
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateFindFilterFragment.kt
@@ -0,0 +1,164 @@
+package com.github.damontecres.stashapp.filter
+
+import android.os.Bundle
+import android.text.InputType
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.SortDirectionEnum
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.data.StashFindFilter
+import com.github.damontecres.stashapp.util.isNotNullOrBlank
+
+class CreateFindFilterFragment(
+ val dataType: DataType,
+ private var currFindFilter: StashFindFilter,
+) : CreateFilterGuidedStepFragment() {
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(R.string.sort_by),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ val sortOptions =
+ buildList {
+ dataType.sortOptions.forEachIndexed { index, sortOption ->
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(SORT_OFFSET + index)
+ .hasNext(false)
+ .title(getString(sortOption.nameStringId))
+ .build(),
+ )
+ }
+ }
+ val currSortOption =
+ dataType.sortOptions.firstOrNull { it.key == currFindFilter.sortAndDirection?.sort }
+ val sortDesc =
+ if (currSortOption != null) {
+ getString(currSortOption.nameStringId)
+ } else {
+ null
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(SORT)
+ .hasNext(true)
+ .subActions(sortOptions)
+ .title(getString(R.string.sort_by))
+ .description(sortDesc)
+ .build(),
+ )
+
+ val directionOptions =
+ buildList {
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(DIRECTION_OFFSET + SortDirectionEnum.ASC.ordinal)
+ .hasNext(false)
+ .title(getString(R.string.stashapp_ascending))
+ .build(),
+ )
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(DIRECTION_OFFSET + SortDirectionEnum.DESC.ordinal)
+ .hasNext(false)
+ .title(getString(R.string.stashapp_descending))
+ .build(),
+ )
+ }
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(DIRECTION)
+ .hasNext(true)
+ .subActions(directionOptions)
+ .title(getString(R.string.stashapp_config_ui_image_wall_direction))
+ .description(getDirectionString(currFindFilter.sortAndDirection?.direction))
+ .build(),
+ )
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(QUERY)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_component_tagger_noun_query))
+ .descriptionEditable(true)
+ .descriptionEditInputType(InputType.TYPE_CLASS_TEXT)
+ .editDescription(currFindFilter.q)
+ .build(),
+ )
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_FINISH)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_save))
+ .build(),
+ )
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_CANCEL)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_cancel))
+ .build(),
+ )
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= SORT_OFFSET) {
+ val newSort = dataType.sortOptions[(action.id - SORT_OFFSET).toInt()]
+ currFindFilter = currFindFilter.withSort(newSort.key)
+ findActionById(SORT).description = getString(newSort.nameStringId)
+ notifyActionChanged(findActionPositionById(SORT))
+ } else if (action.id >= DIRECTION_OFFSET) {
+ val newDirection = SortDirectionEnum.entries[(action.id - DIRECTION_OFFSET).toInt()]
+ currFindFilter = currFindFilter.withDirection(newDirection, dataType)
+ findActionById(DIRECTION).description = getDirectionString(newDirection)
+ notifyActionChanged(findActionPositionById(DIRECTION))
+ }
+ return true
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ val newQuery = findActionById(QUERY).description?.toString()
+ val newValue =
+ if (newQuery.isNotNullOrBlank()) {
+ currFindFilter.copy(q = newQuery)
+ } else {
+ currFindFilter
+ }
+ viewModel.findFilter.value = newValue
+ parentFragmentManager.popBackStack()
+ } else if (action.id == GuidedAction.ACTION_ID_CANCEL) {
+ parentFragmentManager.popBackStack()
+ }
+ }
+
+ private fun getDirectionString(direction: SortDirectionEnum?): String? {
+ return when (direction) {
+ SortDirectionEnum.ASC -> getString(R.string.stashapp_ascending)
+ SortDirectionEnum.DESC -> getString(R.string.stashapp_descending)
+ else -> null
+ }
+ }
+
+ companion object {
+ private const val TAG = "SortPickerFragment"
+ private const val DIRECTION = 2L
+ private const val SORT = 3L
+ private const val QUERY = 4L
+
+ private const val DIRECTION_OFFSET = 1_000L
+ private const val SORT_OFFSET = 2_000L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/CreateObjectFilterStep.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateObjectFilterStep.kt
new file mode 100644
index 00000000..84d9048b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/CreateObjectFilterStep.kt
@@ -0,0 +1,163 @@
+package com.github.damontecres.stashapp.filter
+
+import android.os.Bundle
+import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.DateCriterionInput
+import com.github.damontecres.stashapp.api.type.HierarchicalMultiCriterionInput
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.MultiCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.filter.picker.BooleanPickerFragment
+import com.github.damontecres.stashapp.filter.picker.DatePickerFragment
+import com.github.damontecres.stashapp.filter.picker.HierarchicalMultiCriterionFragment
+import com.github.damontecres.stashapp.filter.picker.IntPickerFragment
+import com.github.damontecres.stashapp.filter.picker.MultiCriterionFragment
+import com.github.damontecres.stashapp.filter.picker.RatingPickerFragment
+import com.github.damontecres.stashapp.filter.picker.StringPickerFragment
+import com.github.damontecres.stashapp.util.QueryEngine
+import com.github.damontecres.stashapp.util.StashServer
+
+class CreateObjectFilterStep : CreateFilterGuidedStepFragment() {
+ private lateinit var queryEngine: QueryEngine
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ queryEngine = QueryEngine(StashServer.requireCurrentServer())
+ }
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ val text =
+ filterSummary(
+ viewModel.dataType.value!!,
+ viewModel.dataType.value!!.filterType,
+ viewModel.objectFilter.value!!,
+ viewModel::lookupIds,
+ )
+ return GuidanceStylist.Guidance(
+ "Create filter",
+ text,
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ getFilterOptions(viewModel.dataType.value!!)
+ .mapIndexed { index, filterOption ->
+ filterOption as FilterOption
+ val value = viewModel.getValue(filterOption)
+ val description =
+ if (value != null) {
+ filterSummary(filterOption.name, value, viewModel::lookupIds)
+ } else {
+ null
+ }
+ createAction(index, filterOption.nameStringId, description)
+ }
+ .sortedBy { it.title.toString() }
+ .forEach {
+ actions.add(it)
+ }
+ }
+
+ override fun onCreateButtonActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(SUBMIT)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_continue))
+ .build(),
+ )
+ }
+
+ private fun createAction(
+ index: Int,
+ @StringRes nameId: Int,
+ description: String?,
+ ): GuidedAction {
+ return GuidedAction.Builder(requireContext())
+ .id(index.toLong())
+ .hasNext(true)
+ .title(nameId)
+ .description(description)
+ .build()
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ val dataType = viewModel.dataType.value!!
+
+ if (action.id == SUBMIT) {
+ parentFragmentManager.popBackStack()
+ } else {
+ val filterOption = getFilterOptions(dataType)[action.id.toInt()]
+ when (action.id.toInt()) {
+ R.string.stashapp_rating -> {
+ filterOption as FilterOption
+ nextStep(RatingPickerFragment(filterOption))
+ }
+
+ else ->
+ when (filterOption.type) {
+ IntCriterionInput::class -> {
+ filterOption as FilterOption
+ nextStep(IntPickerFragment(filterOption))
+ }
+
+ Boolean::class -> {
+ filterOption as FilterOption
+ nextStep(BooleanPickerFragment(filterOption))
+ }
+
+ StringCriterionInput::class -> {
+ filterOption as FilterOption
+ nextStep(StringPickerFragment(filterOption))
+ }
+
+ DateCriterionInput::class -> {
+ filterOption as FilterOption
+ nextStep(DatePickerFragment(filterOption))
+ }
+
+ MultiCriterionInput::class -> {
+ filterOption as FilterOption
+ nextStep(
+ MultiCriterionFragment(
+ filterOption.dataType!!,
+ filterOption,
+ ),
+ )
+ }
+
+ HierarchicalMultiCriterionInput::class -> {
+ filterOption as FilterOption
+ nextStep(
+ HierarchicalMultiCriterionFragment(
+ filterOption.dataType!!,
+ filterOption,
+ ),
+ )
+ }
+
+ else -> TODO()
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val TAG = CreateObjectFilterStep::class.simpleName
+
+ private const val SUBMIT = GuidedAction.ACTION_ID_OK
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/DescriptionExtractors.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/DescriptionExtractors.kt
new file mode 100644
index 00000000..387fff1c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/DescriptionExtractors.kt
@@ -0,0 +1,389 @@
+package com.github.damontecres.stashapp.filter
+
+import android.content.Context
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.StashApplication
+import com.github.damontecres.stashapp.api.fragment.FullSceneData
+import com.github.damontecres.stashapp.api.fragment.GalleryData
+import com.github.damontecres.stashapp.api.fragment.ImageData
+import com.github.damontecres.stashapp.api.fragment.MarkerData
+import com.github.damontecres.stashapp.api.fragment.MovieData
+import com.github.damontecres.stashapp.api.fragment.PerformerData
+import com.github.damontecres.stashapp.api.fragment.SlimSceneData
+import com.github.damontecres.stashapp.api.fragment.StashData
+import com.github.damontecres.stashapp.api.fragment.StudioData
+import com.github.damontecres.stashapp.api.fragment.TagData
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.DateCriterionInput
+import com.github.damontecres.stashapp.api.type.FloatCriterionInput
+import com.github.damontecres.stashapp.api.type.HierarchicalMultiCriterionInput
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.MultiCriterionInput
+import com.github.damontecres.stashapp.api.type.OrientationCriterionInput
+import com.github.damontecres.stashapp.api.type.PHashDuplicationCriterionInput
+import com.github.damontecres.stashapp.api.type.PhashDistanceCriterionInput
+import com.github.damontecres.stashapp.api.type.ResolutionCriterionInput
+import com.github.damontecres.stashapp.api.type.SortDirectionEnum
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.api.type.StashIDCriterionInput
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.api.type.TimestampCriterionInput
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.data.StashFindFilter
+import com.github.damontecres.stashapp.filter.output.FilterWriter
+import com.github.damontecres.stashapp.filter.output.getAllIds
+import com.github.damontecres.stashapp.util.titleOrFilename
+import com.github.damontecres.stashapp.views.durationToString
+import com.github.damontecres.stashapp.views.getString
+import kotlin.reflect.KClass
+import kotlin.reflect.full.declaredMemberProperties
+
+fun extractTitle(item: StashData): String? {
+ return when (item) {
+ is TagData -> item.name
+ is PerformerData -> item.name
+ is StudioData -> item.name
+ is GalleryData -> item.title
+ is ImageData -> item.title
+ is MarkerData -> item.title
+ is MovieData -> item.name
+ is SlimSceneData -> item.titleOrFilename
+ is FullSceneData -> item.titleOrFilename
+ else -> throw IllegalArgumentException("${item::class.qualifiedName} not supported")
+ }
+}
+
+fun extractDescription(item: StashData): String? {
+ return when (item) {
+ is TagData -> item.description?.ifBlank { null }
+ is PerformerData -> item.disambiguation
+ is StudioData -> null
+ is GalleryData -> item.date
+ is ImageData -> item.date
+ is MarkerData -> "${item.scene.videoSceneData.titleOrFilename} (${durationToString(item.seconds)})"
+ is MovieData -> item.date
+ is SlimSceneData -> item.date
+ is FullSceneData -> item.date
+ else -> throw IllegalArgumentException("${item::class.qualifiedName} not supported")
+ }
+}
+
+fun findFilterSummary(
+ context: Context,
+ dataType: DataType,
+ findFilter: StashFindFilter,
+): String {
+ val sortAndDirection = findFilter.sortAndDirection ?: dataType.defaultSort
+ val sortOption = dataType.sortOptions.firstOrNull { it.key == sortAndDirection.sort }
+ val sortName =
+ if (sortOption != null) {
+ context.getString(sortOption.nameStringId)
+ } else {
+ sortAndDirection.sort
+ }
+ val directionName =
+ when (sortAndDirection.direction) {
+ SortDirectionEnum.ASC -> context.getString(R.string.stashapp_ascending)
+ SortDirectionEnum.DESC -> context.getString(R.string.stashapp_descending)
+ SortDirectionEnum.UNKNOWN__ -> null
+ }
+ return "$sortName, $directionName"
+}
+
+fun filterSummary(
+ f: MultiCriterionInput,
+ itemMap: Map,
+): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value.getOrNull()
+ val resolvedTitles = value?.map { itemMap[it]?.name ?: it }.orEmpty()
+ val toStr =
+ when (f.modifier) {
+ CriterionModifier.EQUALS -> resolvedTitles.firstOrNull() ?: ""
+ CriterionModifier.IS_NULL -> ""
+ CriterionModifier.NOT_NULL -> ""
+ CriterionModifier.INCLUDES_ALL -> resolvedTitles.toString()
+ CriterionModifier.INCLUDES -> resolvedTitles.toString()
+ else -> throw IllegalArgumentException("${f.modifier}")
+ }.ifBlank { null }
+ // TODO excludes
+ return if (toStr != null) {
+ "$modStr $toStr"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(
+ f: HierarchicalMultiCriterionInput,
+ itemMap: Map,
+): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value.getOrNull()
+ val resolvedTitles = value?.map { itemMap[it]?.name ?: it }.orEmpty()
+ val toStr =
+ when (f.modifier) {
+ CriterionModifier.EQUALS -> resolvedTitles.firstOrNull() ?: ""
+ CriterionModifier.IS_NULL -> ""
+ CriterionModifier.NOT_NULL -> ""
+ CriterionModifier.INCLUDES_ALL -> resolvedTitles.toString()
+ CriterionModifier.INCLUDES -> resolvedTitles.toString()
+ else -> throw IllegalArgumentException("${f.modifier}")
+ }.ifBlank { null }
+ // TODO excludes
+ return if (toStr != null) {
+ "$modStr $toStr"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(f: StringCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value.ifBlank { null }
+ return if (value != null) {
+ "$modStr $value"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(f: IntCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value
+ val value2 = f.value2.getOrNull()
+ val toStr =
+ when (f.modifier) {
+ CriterionModifier.EQUALS,
+ CriterionModifier.NOT_EQUALS,
+ CriterionModifier.GREATER_THAN,
+ CriterionModifier.LESS_THAN,
+ -> value.toString()
+
+ CriterionModifier.IS_NULL, CriterionModifier.NOT_NULL -> null
+
+ CriterionModifier.BETWEEN, CriterionModifier.NOT_BETWEEN -> "$value & $value2"
+
+ else -> throw IllegalArgumentException("${f.modifier}")
+ }
+
+ return if (toStr != null) {
+ "$modStr $toStr"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(f: FloatCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value
+ val value2 = f.value2.getOrNull()
+ val toStr =
+ when (f.modifier) {
+ CriterionModifier.EQUALS,
+ CriterionModifier.NOT_EQUALS,
+ CriterionModifier.GREATER_THAN,
+ CriterionModifier.LESS_THAN,
+ -> value.toString()
+
+ CriterionModifier.IS_NULL, CriterionModifier.NOT_NULL -> null
+
+ CriterionModifier.BETWEEN, CriterionModifier.NOT_BETWEEN -> "$value & $value2"
+
+ else -> throw IllegalArgumentException("${f.modifier}")
+ }
+
+ return if (toStr != null) {
+ "$modStr $toStr"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(f: PhashDistanceCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val distance = f.distance.getOrNull()
+ if (distance != null) {
+ return "$modStr ${f.value} ($distance)"
+ } else {
+ return "$modStr ${f.value}"
+ }
+}
+
+fun filterSummary(f: PHashDuplicationCriterionInput): String {
+ val duplicated = f.duplicated.getOrNull()
+ val distance = f.distance.getOrNull()
+ if (distance != null) {
+ return "$duplicated ($distance)"
+ } else {
+ return "$duplicated"
+ }
+}
+
+fun filterSummary(f: ResolutionCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ // TODO map strings
+ return "$modStr ${f.value.name}"
+}
+
+fun filterSummary(f: OrientationCriterionInput): String {
+ // TODO map strings
+ return f.value.map { it.name }.toString()
+}
+
+fun filterSummary(f: StashIDCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val stashId = f.stash_id.getOrNull()
+ val endpoint = f.endpoint.getOrNull()
+ return if (endpoint != null) {
+ "$modStr $stashId ($endpoint)"
+ } else {
+ "$modStr $stashId"
+ }
+}
+
+fun filterSummary(f: TimestampCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value
+ val value2 = f.value2.getOrNull()
+ val toStr =
+ when (f.modifier) {
+ CriterionModifier.EQUALS,
+ CriterionModifier.NOT_EQUALS,
+ CriterionModifier.GREATER_THAN,
+ CriterionModifier.LESS_THAN,
+ -> value
+
+ CriterionModifier.IS_NULL, CriterionModifier.NOT_NULL -> null
+
+ CriterionModifier.BETWEEN, CriterionModifier.NOT_BETWEEN -> "$value & $value2"
+
+ else -> throw IllegalArgumentException("${f.modifier}")
+ }
+
+ return if (toStr != null) {
+ "$modStr $toStr"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(f: DateCriterionInput): String {
+ val modStr = f.modifier.getString(StashApplication.getApplication())
+ val value = f.value
+ val value2 = f.value2.getOrNull()
+ val toStr =
+ when (f.modifier) {
+ CriterionModifier.EQUALS,
+ CriterionModifier.NOT_EQUALS,
+ CriterionModifier.GREATER_THAN,
+ CriterionModifier.LESS_THAN,
+ -> value
+
+ CriterionModifier.IS_NULL, CriterionModifier.NOT_NULL -> null
+
+ CriterionModifier.BETWEEN, CriterionModifier.NOT_BETWEEN -> "$value & $value2"
+
+ else -> throw IllegalArgumentException("${f.modifier}")
+ }
+
+ return if (toStr != null) {
+ "$modStr $toStr"
+ } else {
+ modStr
+ }
+}
+
+fun filterSummary(
+ name: String,
+ value: Any,
+ idLookup: (DataType, List) -> Map,
+): String {
+ return when (value) {
+ is IntCriterionInput -> filterSummary(value)
+ is FloatCriterionInput -> filterSummary(value)
+ is StringCriterionInput -> filterSummary(value)
+ is PhashDistanceCriterionInput -> filterSummary(value)
+ is PHashDuplicationCriterionInput -> filterSummary(value)
+ is ResolutionCriterionInput -> filterSummary(value)
+ is OrientationCriterionInput -> filterSummary(value)
+ is StashIDCriterionInput -> filterSummary(value)
+ is TimestampCriterionInput -> filterSummary(value)
+ is DateCriterionInput -> filterSummary(value)
+
+ is Boolean, String -> value.toString()
+
+ is MultiCriterionInput -> {
+ val dataType = FilterWriter.TYPE_MAPPING[name]!!
+ filterSummary(value, idLookup(dataType, value.getAllIds()))
+ }
+
+ is HierarchicalMultiCriterionInput -> {
+ val dataType = FilterWriter.TYPE_MAPPING[name]!!
+ filterSummary(value, idLookup(dataType, value.getAllIds()))
+ }
+
+ // TODO
+ else -> value.toString()
+ }
+}
+
+fun filterSummary(
+ dataType: DataType,
+ type: KClass,
+ f: StashDataFilter,
+ idLookup: (DataType, List) -> Map,
+): String {
+ val filterOptionNames = FilterOptions[dataType]!!.associateBy { it.name }
+ val params =
+ type.declaredMemberProperties.mapNotNull { param ->
+ val obj = param.get(f) as Optional<*>
+ val value = obj.getOrNull()
+ if (value != null) {
+ val valueStr = filterSummary(param.name, value, idLookup)
+ val nameStringId = filterOptionNames[param.name]?.nameStringId
+ val key =
+ if (nameStringId != null) {
+ StashApplication.getApplication().getString(nameStringId)
+ } else {
+ param.name
+ }
+ key to valueStr
+ } else {
+ null
+ }
+ }.sortedBy { it.first }
+ val text =
+ params.joinToString("\n") {
+ "${it.first} ${it.second}"
+ }
+ return text
+}
+
+fun getIdsByDataType(
+ type: KClass,
+ f: StashDataFilter,
+): Map> {
+ val result =
+ buildMap {
+ type.declaredMemberProperties.forEach { param ->
+ val obj = param.get(f) as Optional<*>
+ val value = obj.getOrNull()
+ if (value != null) {
+ when (value) {
+ is MultiCriterionInput -> {
+ val dataType = FilterWriter.TYPE_MAPPING[param.name]!!
+ put(dataType, value.getAllIds())
+ }
+
+ is HierarchicalMultiCriterionInput -> {
+ val dataType = FilterWriter.TYPE_MAPPING[param.name]!!
+ put(dataType, value.getAllIds())
+ }
+ }
+ }
+ }
+ }
+ return result
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/FilterOption.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/FilterOption.kt
new file mode 100644
index 00000000..683b05d8
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/FilterOption.kt
@@ -0,0 +1,180 @@
+package com.github.damontecres.stashapp.filter
+
+import androidx.annotation.StringRes
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.DateCriterionInput
+import com.github.damontecres.stashapp.api.type.HierarchicalMultiCriterionInput
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.MultiCriterionInput
+import com.github.damontecres.stashapp.api.type.PerformerFilterType
+import com.github.damontecres.stashapp.api.type.SceneFilterType
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.data.DataType
+import kotlin.reflect.KClass
+
+/**
+ * A way to filter a particular [DataType]
+ *
+ * @param name the key for the filter
+ * @param nameStringId ID for human readable name
+ * @param dataType the type of this sub-filter, not the overarching filter
+ * @param type the type of this sub-filter
+ * @param getter how to get the value of this from the filter
+ * @param setter how to set the value of this on the filter (returning a copy)
+ */
+data class FilterOption(
+ val name: String,
+ @StringRes val nameStringId: Int,
+ val dataType: DataType?,
+ val type: KClass,
+ val getter: (FilterType) -> Optional,
+ val setter: (FilterType, Optional) -> FilterType,
+)
+
+private val SceneFilterOptions =
+ listOf(
+ FilterOption(
+ "date",
+ R.string.stashapp_date,
+ DataType.TAG,
+ DateCriterionInput::class,
+ { filter -> filter.date },
+ { filter, value -> filter.copy(date = value) },
+ ),
+ FilterOption(
+ "director",
+ R.string.stashapp_director,
+ null,
+ StringCriterionInput::class,
+ { it.director },
+ { filter, value -> filter.copy(director = value) },
+ ),
+ FilterOption(
+ "movies",
+ R.string.stashapp_movies,
+ DataType.MOVIE,
+ MultiCriterionInput::class,
+ { it.movies },
+ { filter, value -> filter.copy(movies = value) },
+ ),
+ FilterOption(
+ "performer_age",
+ R.string.stashapp_performer_age,
+ null,
+ IntCriterionInput::class,
+ { it.performer_age },
+ { filter, value -> filter.copy(performer_age = value) },
+ ),
+ FilterOption(
+ "performer_count",
+ R.string.stashapp_performer_count,
+ null,
+ IntCriterionInput::class,
+ { it.performer_count },
+ { filter, value -> filter.copy(performer_count = value) },
+ ),
+ FilterOption(
+ "performer_favorite",
+ R.string.stashapp_performer_favorite,
+ null,
+ Boolean::class,
+ { it.performer_favorite },
+ { filter, value -> filter.copy(performer_favorite = value) },
+ ),
+ FilterOption(
+ "performer_tags",
+ R.string.stashapp_performer_tags,
+ DataType.TAG,
+ HierarchicalMultiCriterionInput::class,
+ { filter -> filter.performer_tags },
+ { filter, value -> filter.copy(performer_tags = value) },
+ ),
+ FilterOption(
+ "performers",
+ R.string.stashapp_performers,
+ DataType.PERFORMER,
+ MultiCriterionInput::class,
+ { it.performers },
+ { filter, value -> filter.copy(performers = value) },
+ ),
+ FilterOption(
+ "play_count",
+ R.string.stashapp_play_count,
+ null,
+ IntCriterionInput::class,
+ { it.play_count },
+ { filter, value -> filter.copy(play_count = value) },
+ ),
+ FilterOption(
+ "o_counter",
+ R.string.stashapp_o_counter,
+ null,
+ IntCriterionInput::class,
+ { it.o_counter },
+ { filter, value -> filter.copy(o_counter = value) },
+ ),
+ FilterOption(
+ "rating100",
+ R.string.stashapp_rating,
+ null,
+ IntCriterionInput::class,
+ { it.rating100 },
+ { filter, value -> filter.copy(rating100 = value) },
+ ),
+ FilterOption(
+ "studios",
+ R.string.stashapp_studios,
+ DataType.STUDIO,
+ HierarchicalMultiCriterionInput::class,
+ { filter -> filter.studios },
+ { filter, value -> filter.copy(studios = value) },
+ ),
+ FilterOption(
+ "tags",
+ R.string.stashapp_tags,
+ DataType.TAG,
+ HierarchicalMultiCriterionInput::class,
+ { filter -> filter.tags },
+ { filter, value -> filter.copy(tags = value) },
+ ),
+ FilterOption(
+ "title",
+ R.string.stashapp_title,
+ null,
+ StringCriterionInput::class,
+ { it.title },
+ { filter, value -> filter.copy(title = value) },
+ ),
+ )
+
+private val PerformerFilterOptions =
+ listOf(
+ FilterOption(
+ "name",
+ R.string.stashapp_name,
+ null,
+ StringCriterionInput::class,
+ { it.name },
+ { filter, value -> filter.copy(name = value) },
+ ),
+ FilterOption(
+ "tags",
+ R.string.stashapp_tags,
+ DataType.TAG,
+ HierarchicalMultiCriterionInput::class,
+ { filter -> filter.tags },
+ { filter, value -> filter.copy(tags = value) },
+ ),
+ )
+
+val FilterOptions =
+ mapOf(
+ DataType.SCENE to SceneFilterOptions,
+ DataType.PERFORMER to PerformerFilterOptions,
+ )
+
+fun getFilterOptions(dataType: DataType): List> {
+ return FilterOptions[dataType] ?: throw UnsupportedOperationException("$dataType")
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterOutputs.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterOutputs.kt
new file mode 100644
index 00000000..0db67360
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterOutputs.kt
@@ -0,0 +1,221 @@
+package com.github.damontecres.stashapp.filter.output
+
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.DateCriterionInput
+import com.github.damontecres.stashapp.api.type.FloatCriterionInput
+import com.github.damontecres.stashapp.api.type.HierarchicalMultiCriterionInput
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.MultiCriterionInput
+import com.github.damontecres.stashapp.api.type.OrientationCriterionInput
+import com.github.damontecres.stashapp.api.type.OrientationEnum
+import com.github.damontecres.stashapp.api.type.PHashDuplicationCriterionInput
+import com.github.damontecres.stashapp.api.type.PhashDistanceCriterionInput
+import com.github.damontecres.stashapp.api.type.ResolutionCriterionInput
+import com.github.damontecres.stashapp.api.type.ResolutionEnum
+import com.github.damontecres.stashapp.api.type.StashIDCriterionInput
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.api.type.TimestampCriterionInput
+
+fun IntCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ put("value", value)
+ if (value2.getOrNull() != null) {
+ put("value2", value2.getOrNull()!!)
+ }
+ },
+ )
+ }
+
+fun FloatCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ put("value", value)
+ if (value2.getOrNull() != null) {
+ put("value2", value2.getOrNull()!!)
+ }
+ },
+ )
+ }
+
+fun StringCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put("value", value)
+ }
+
+fun MultiCriterionInput.getAllIds() = value.getOrNull().orEmpty() + excludes.getOrNull().orEmpty()
+
+fun MultiCriterionInput.toMap(labelMapping: Map): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ val items =
+ value.getOrNull().orEmpty().map { id ->
+ buildMap {
+ put("id", id)
+ put("label", labelMapping[id])
+ }
+ }
+ if (items.isNotEmpty()) {
+ put("items", items)
+ }
+ val excludes =
+ excludes.getOrNull().orEmpty().map { id ->
+ buildMap {
+ put("id", id)
+ put("label", labelMapping[id])
+ }
+ }
+ if (excludes.isNotEmpty()) {
+ put("excludes", excludes)
+ }
+ },
+ )
+ }
+
+fun HierarchicalMultiCriterionInput.getAllIds() = value.getOrNull().orEmpty() + excludes.getOrNull().orEmpty()
+
+fun HierarchicalMultiCriterionInput.toMap(labelMapping: Map): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put("depth", depth.getOrNull() ?: 0)
+ put(
+ "value",
+ buildMap {
+ val items =
+ value.getOrNull().orEmpty().map { id ->
+ buildMap {
+ put("id", id)
+ put("label", labelMapping[id])
+ }
+ }
+ if (items.isNotEmpty()) {
+ put("items", items)
+ }
+ val excludes =
+ excludes.getOrNull().orEmpty().map { id ->
+ buildMap {
+ put("id", id)
+ put("label", labelMapping[id])
+ }
+ }
+ if (excludes.isNotEmpty()) {
+ put("excludes", excludes)
+ }
+ },
+ )
+ }
+
+fun PhashDistanceCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ put("value", value)
+ if (distance.getOrNull() != null) {
+ put("distance", distance.getOrNull()!!)
+ }
+ },
+ )
+ }
+
+fun PHashDuplicationCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", CriterionModifier.EQUALS.rawValue)
+ if (duplicated.getOrNull() == false) {
+ put("value", false)
+ } else {
+ put("value", true)
+ }
+ }
+
+fun ResolutionCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ val name =
+ when (value) {
+ ResolutionEnum.VERY_LOW -> "144p"
+ ResolutionEnum.LOW -> "240p"
+ ResolutionEnum.R360P -> "360p"
+ ResolutionEnum.STANDARD -> "480p"
+ ResolutionEnum.WEB_HD -> "540p"
+ ResolutionEnum.STANDARD_HD -> "720p"
+ ResolutionEnum.FULL_HD -> "1080p"
+ ResolutionEnum.QUAD_HD -> "1440p"
+ ResolutionEnum.VR_HD -> "4k"
+ ResolutionEnum.FOUR_K -> "4k"
+ ResolutionEnum.FIVE_K -> "5k"
+ ResolutionEnum.SIX_K -> "6k"
+ ResolutionEnum.SEVEN_K -> "7k"
+ ResolutionEnum.EIGHT_K -> "8k"
+ ResolutionEnum.HUGE -> "Huge"
+ ResolutionEnum.UNKNOWN__ -> throw IllegalArgumentException()
+ }
+ put("value", name)
+ }
+
+fun OrientationCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", CriterionModifier.EQUALS.rawValue)
+ put(
+ "value",
+ value.map {
+ when (it) {
+ OrientationEnum.LANDSCAPE -> "Landscape"
+ OrientationEnum.PORTRAIT -> "Portrait"
+ OrientationEnum.SQUARE -> "Square"
+ OrientationEnum.UNKNOWN__ -> throw IllegalArgumentException()
+ }
+ },
+ )
+ }
+
+fun StashIDCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ put("stashID", stash_id.getOrNull() ?: "")
+ put("endpoint", endpoint.getOrNull() ?: "")
+ },
+ )
+ }
+
+fun TimestampCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ put("value", value)
+ if (value2.getOrNull() != null) {
+ put("value2", value2.getOrNull()!!)
+ }
+ },
+ )
+ }
+
+fun DateCriterionInput.toMap(): Map =
+ buildMap {
+ put("modifier", modifier.rawValue)
+ put(
+ "value",
+ buildMap {
+ put("value", value)
+ if (value2.getOrNull() != null) {
+ put("value2", value2.getOrNull()!!)
+ }
+ },
+ )
+ }
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterWriter.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterWriter.kt
new file mode 100644
index 00000000..154989da
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/output/FilterWriter.kt
@@ -0,0 +1,103 @@
+package com.github.damontecres.stashapp.filter.output
+
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.api.fragment.PerformerData
+import com.github.damontecres.stashapp.api.fragment.StashData
+import com.github.damontecres.stashapp.api.fragment.TagData
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.DateCriterionInput
+import com.github.damontecres.stashapp.api.type.FloatCriterionInput
+import com.github.damontecres.stashapp.api.type.HierarchicalMultiCriterionInput
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.MultiCriterionInput
+import com.github.damontecres.stashapp.api.type.OrientationCriterionInput
+import com.github.damontecres.stashapp.api.type.PHashDuplicationCriterionInput
+import com.github.damontecres.stashapp.api.type.PhashDistanceCriterionInput
+import com.github.damontecres.stashapp.api.type.ResolutionCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.api.type.StashIDCriterionInput
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.api.type.TimestampCriterionInput
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.util.QueryEngine
+import kotlin.reflect.full.declaredMemberProperties
+
+/**
+ * Converts a [StashDataFilter] into the JSON (Map) representation
+ *
+ * @param queryEngine The webUI requires a "label" for some IDs, so this is used to query for those
+ */
+class FilterWriter(private val queryEngine: QueryEngine) {
+ suspend fun convertFilter(filter: StashDataFilter): Map {
+ val objectFilter =
+ buildMap {
+ filter.javaClass.kotlin.declaredMemberProperties.forEach { param ->
+ val obj = param.get(filter) as Optional<*>
+ if (obj != Optional.Absent) {
+ val o = obj.getOrNull()!!
+ val dataType = TYPE_MAPPING[param.name]
+ val value =
+ when (o) {
+ is IntCriterionInput -> o.toMap()
+ is FloatCriterionInput -> o.toMap()
+ is StringCriterionInput -> o.toMap()
+ is PhashDistanceCriterionInput -> o.toMap()
+ is PHashDuplicationCriterionInput -> o.toMap()
+ is ResolutionCriterionInput -> o.toMap()
+ is OrientationCriterionInput -> o.toMap()
+ is StashIDCriterionInput -> o.toMap()
+ is TimestampCriterionInput -> o.toMap()
+ is DateCriterionInput -> o.toMap()
+
+ is Boolean, String -> {
+ mapOf(
+ "value" to o.toString(),
+ "modifier" to CriterionModifier.EQUALS.rawValue,
+ )
+ }
+ is MultiCriterionInput -> {
+ val items = queryEngine.getByIds(dataType!!, o.getAllIds())
+ o.toMap(associateIds(items))
+ }
+ is HierarchicalMultiCriterionInput -> {
+ val items = queryEngine.getByIds(dataType!!, o.getAllIds())
+ o.toMap(associateIds(items))
+ }
+ else -> TODO()
+ }
+ put(param.name, value)
+ }
+ }
+ }
+ return objectFilter
+ }
+
+ private fun associateIds(items: List): Map {
+ return items.associate {
+ when (it) {
+ is TagData -> it.id to it.name
+ is PerformerData -> it.id to it.name
+ else -> TODO()
+ }
+ }
+ }
+
+ companion object {
+ /**
+ * Map the name of a filter to a [DataType]. Not all filters have a [DataType] though!
+ */
+ val TYPE_MAPPING =
+ mapOf(
+ "performers" to DataType.PERFORMER,
+ "tags" to DataType.TAG,
+ "performer_tags" to DataType.TAG,
+ "studios" to DataType.STUDIO,
+ "movies" to DataType.MOVIE,
+ "groups" to DataType.MOVIE,
+ "galleries" to DataType.GALLERY,
+ "images" to DataType.IMAGE,
+ "scene_markers" to DataType.MARKER,
+ "scenes" to DataType.SCENE,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/BooleanPickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/BooleanPickerFragment.kt
new file mode 100644
index 00000000..f73a0cab
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/BooleanPickerFragment.kt
@@ -0,0 +1,77 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.FilterOption
+
+/**
+ * Select a boolean for a filter or remove it altogether
+ */
+class BooleanPickerFragment(
+ private val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(0L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_true))
+ .build(),
+ )
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(1L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_false))
+ .build(),
+ )
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(2L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_remove))
+ .build(),
+ )
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_CANCEL)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_cancel))
+ .build(),
+ )
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id != GuidedAction.ACTION_ID_CANCEL) {
+ val newValue =
+ when (action.id) {
+ 0L -> true
+ 1L -> false
+ else -> null
+ }
+ viewModel.updateFilter(filterOption, newValue)
+ }
+ parentFragmentManager.popBackStack()
+ }
+
+ companion object {
+ private const val TAG = "BooleanPickerFragment"
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/DatePickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/DatePickerFragment.kt
new file mode 100644
index 00000000..c8711e1a
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/DatePickerFragment.kt
@@ -0,0 +1,136 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import androidx.leanback.widget.GuidedDatePickerAction
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.DateCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.views.getString
+import java.text.ParseException
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Pick a date value
+ */
+class DatePickerFragment(
+ private val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal: DateCriterionInput? = null
+
+ private val format = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ curVal = viewModel.getValue(filterOption)
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ val currDateStr = curVal?.value?.ifBlank { null }
+ val curModifier = curVal?.modifier ?: CriterionModifier.EQUALS
+
+ val dateLong =
+ if (currDateStr != null) {
+ try {
+ format.parse(currDateStr)?.time ?: Date().time
+ } catch (ex: ParseException) {
+ Log.w(TAG, "Parse error: $ex")
+ Date().time
+ }
+ } else {
+ Date().time
+ }
+
+ // TODO show second value for between
+ actions.add(
+ GuidedDatePickerAction.Builder(requireContext())
+ .id(1L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_criterion_value))
+ .date(dateLong)
+ .build(),
+ )
+
+ val modifierOptions =
+ buildList {
+ add(modifierAction(CriterionModifier.EQUALS))
+ add(modifierAction(CriterionModifier.NOT_EQUALS))
+ add(modifierAction(CriterionModifier.GREATER_THAN))
+ add(modifierAction(CriterionModifier.LESS_THAN))
+ add(modifierAction(CriterionModifier.IS_NULL))
+ add(modifierAction(CriterionModifier.NOT_NULL))
+ // TODO: between
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curModifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ addStandardActions(actions, filterOption)
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ val curDate = Date((findActionById(1L) as GuidedDatePickerAction).date)
+ if (action.id >= MODIFIER_OFFSET) {
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal?.copy(modifier = newModifier) ?: DateCriterionInput(
+ value = curVal?.value ?: format.format(curDate),
+ value2 = curVal?.value2 ?: Optional.absent(),
+ modifier = newModifier,
+ )
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ }
+ return true
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ val curDate = Date((findActionById(1L) as GuidedDatePickerAction).date)
+ val dateStr = format.format(curDate)
+ val modifier = curVal?.modifier ?: CriterionModifier.EQUALS
+ val newValue =
+ if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
+ DateCriterionInput(value = "", modifier = modifier)
+ } else {
+ DateCriterionInput(value = dateStr, modifier = modifier)
+ }
+
+ viewModel.updateFilter(filterOption, newValue)
+ parentFragmentManager.popBackStack()
+ } else {
+ onStandardActionClicked(action, filterOption)
+ }
+ }
+
+ companion object {
+ private const val TAG = "FloatPickerFragment"
+ private const val MODIFIER = 2L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/FloatPickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/FloatPickerFragment.kt
new file mode 100644
index 00000000..e3caf3f3
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/FloatPickerFragment.kt
@@ -0,0 +1,142 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.text.InputType
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.FloatCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.views.getString
+
+/**
+ * Pick a decimal/float value
+ */
+class FloatPickerFragment(
+ private val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal: FloatCriterionInput? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ curVal = filterOption.getter(viewModel.objectFilter.value!!).getOrNull()
+ super.onCreate(savedInstanceState)
+ }
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ val curInt = curVal?.value
+ val curModifier = curVal?.modifier ?: CriterionModifier.EQUALS
+
+ // TODO show second value for between
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(1L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_criterion_value))
+ .descriptionEditable(true)
+ // TODO handle signed vs not?
+ .descriptionEditInputType(
+ InputType.TYPE_CLASS_NUMBER or
+ InputType.TYPE_NUMBER_FLAG_SIGNED or
+ InputType.TYPE_NUMBER_FLAG_DECIMAL,
+ )
+ .editDescription(curInt?.toString())
+ .build(),
+ )
+
+ val modifierOptions =
+ buildList {
+ add(modifierAction(CriterionModifier.EQUALS))
+ add(modifierAction(CriterionModifier.NOT_EQUALS))
+ add(modifierAction(CriterionModifier.GREATER_THAN))
+ add(modifierAction(CriterionModifier.LESS_THAN))
+ // TODO: between
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curModifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ addStandardActions(actions, filterOption)
+ }
+
+ override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long {
+ if (action.id == 1L) {
+ val desc = action.description
+ try {
+ if (desc != null) {
+ desc.toString().toDouble()
+ enableFinish(true)
+ return GuidedAction.ACTION_ID_NEXT
+ }
+ } catch (ex: Exception) {
+ Toast.makeText(requireContext(), "Invalid decimal: $desc", Toast.LENGTH_SHORT)
+ .show()
+ }
+ enableFinish(false)
+ return GuidedAction.ACTION_ID_NEXT
+ }
+ return GuidedAction.ACTION_ID_CURRENT
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= MODIFIER_OFFSET) {
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal?.copy(modifier = newModifier) ?: FloatCriterionInput(
+ value = curVal?.value ?: 0.0,
+ value2 = curVal?.value2 ?: Optional.absent(),
+ modifier = newModifier,
+ )
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ }
+ return true
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ val newInt = findActionById(1L).description?.toString()?.toDouble()
+ val modifier = curVal?.modifier ?: CriterionModifier.EQUALS
+ val newValue =
+ if (newInt != null) {
+ FloatCriterionInput(value = newInt, modifier = modifier)
+ } else if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
+ FloatCriterionInput(value = 0.0, modifier = modifier)
+ } else {
+ null
+ }
+
+ viewModel.updateFilter(filterOption, newValue)
+ parentFragmentManager.popBackStack()
+ } else {
+ onStandardActionClicked(action, filterOption)
+ }
+ }
+
+ companion object {
+ private const val TAG = "FloatPickerFragment"
+ private const val MODIFIER = 2L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/HierarchicalMultiCriterionFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/HierarchicalMultiCriterionFragment.kt
new file mode 100644
index 00000000..bf5ba569
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/HierarchicalMultiCriterionFragment.kt
@@ -0,0 +1,177 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.commit
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.HierarchicalMultiCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.CreateFilterViewModel
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.views.getString
+
+class HierarchicalMultiCriterionFragment(
+ private val dataType: DataType,
+ private val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal = HierarchicalMultiCriterionInput(modifier = CriterionModifier.INCLUDES_ALL)
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "Click to remove an item",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ curVal = filterOption.getter.invoke(
+ viewModel.objectFilter.value!!,
+ ).getOrNull() ?: HierarchicalMultiCriterionInput(modifier = CriterionModifier.INCLUDES_ALL)
+
+ val modifierOptions =
+ buildList {
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER_OFFSET + CriterionModifier.INCLUDES.ordinal.toLong())
+ .hasNext(false)
+ .title(CriterionModifier.INCLUDES.getString(requireContext()))
+ .build(),
+ )
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER_OFFSET + CriterionModifier.INCLUDES_ALL.ordinal.toLong())
+ .hasNext(false)
+ .title(CriterionModifier.INCLUDES_ALL.getString(requireContext()))
+ .build(),
+ )
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curVal.modifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ val includeItems = createItemList(curVal.value.getOrNull().orEmpty())
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(INCLUDE_LIST)
+ .hasNext(false)
+ .title(getString(dataType.pluralStringId))
+ .description("${includeItems.size - 1} ${getString(dataType.pluralStringId)}")
+ .subActions(includeItems)
+ .build(),
+ )
+ // TODO excludes
+
+ addStandardActions(actions, filterOption)
+ }
+
+ private fun createItemList(ids: List): List =
+ buildList {
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(ADD_INCLUDE_ITEM)
+ .title("Add")
+ .build(),
+ )
+ addAll(
+ ids.mapIndexed { index, id ->
+ val nameDesc =
+ viewModel.storedItems[CreateFilterViewModel.DataTypeId(dataType, id)]
+ GuidedAction.Builder(requireContext())
+ .id(INCLUDE_OFFSET + index)
+ .title(nameDesc?.name)
+ .description(nameDesc?.description)
+ .build()
+ }.sortedBy { it.title.toString() },
+ )
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ viewModel.updateFilter(filterOption, curVal)
+ parentFragmentManager.popBackStack()
+ } else {
+ onStandardActionClicked(action, filterOption)
+ }
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= MODIFIER_OFFSET) {
+ // Update the modifier
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal.copy(modifier = newModifier)
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ return true
+ } else if (action.id >= EXCLUDE_OFFSET) {
+ TODO()
+ } else if (action.id >= INCLUDE_OFFSET) {
+ // Item was clicked, so remove it
+ val index = action.id - INCLUDE_OFFSET
+ val list = curVal.value.getOrThrow()!!.toMutableList()
+ list.removeAt(index.toInt())
+ curVal = curVal.copy(value = Optional.present(list))
+
+ val action = findActionById(INCLUDE_LIST)
+ action.subActions = createItemList(list)
+ action.description = "${list.size} ${getString(dataType.pluralStringId)}"
+ notifyActionChanged(findActionPositionById(INCLUDE_LIST))
+ return true
+ } else if (action.id == ADD_INCLUDE_ITEM) {
+ // Add a new item
+ requireActivity().supportFragmentManager.commit {
+ addToBackStack("picker")
+ replace(
+ android.R.id.content,
+ SearchPickerFragment(dataType) { newItem ->
+ Log.v(TAG, "Adding ${newItem.id}")
+ viewModel.store(dataType, newItem)
+ val list = curVal.value.getOrNull()?.toMutableList() ?: ArrayList()
+ if (!list.contains(newItem.id)) {
+ list.add(newItem.id)
+ curVal = curVal.copy(value = Optional.present(list))
+ val action = findActionById(INCLUDE_LIST)
+ action.subActions = createItemList(list)
+ action.description =
+ "${list.size} ${getString(dataType.pluralStringId)}"
+ notifyActionChanged(findActionPositionById(INCLUDE_LIST))
+ }
+ },
+ )
+ }
+ } else if (action.id == ADD_EXCLUDE_ITEM) {
+ TODO()
+ }
+ return false
+ }
+
+ companion object {
+ private const val TAG = "HierarchicalMultiCriterionFragment"
+
+ private const val MODIFIER = 1L
+ private const val INCLUDE_LIST = 2L
+ private const val ADD_INCLUDE_ITEM = 3L
+ private const val ADD_EXCLUDE_ITEM = 4L
+ private const val SUBMIT = 5L
+
+ private const val INCLUDE_OFFSET = 1_000_000L
+ private const val EXCLUDE_OFFSET = 2_000_000L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/IntPickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/IntPickerFragment.kt
new file mode 100644
index 00000000..f2810d1b
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/IntPickerFragment.kt
@@ -0,0 +1,133 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.text.InputType
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.views.getString
+
+class IntPickerFragment(
+ val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal: IntCriterionInput? = null
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ curVal = filterOption.getter(viewModel.objectFilter.value!!).getOrNull()
+ val curInt = curVal?.value
+ val curModifier = curVal?.modifier ?: CriterionModifier.EQUALS
+
+ // TODO show second value for between
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(1L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_criterion_value))
+ .descriptionEditable(true)
+ .descriptionEditInputType(InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_SIGNED)
+ .editDescription(curInt?.toString())
+ .build(),
+ )
+
+ val modifierOptions =
+ buildList {
+ add(modifierAction(CriterionModifier.EQUALS))
+ add(modifierAction(CriterionModifier.NOT_EQUALS))
+ add(modifierAction(CriterionModifier.GREATER_THAN))
+ add(modifierAction(CriterionModifier.LESS_THAN))
+ // TODO: between
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curModifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ addStandardActions(actions, filterOption)
+ }
+
+ override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long {
+ if (action.id == 1L) {
+ // THe value was changed, so check if it valid or not
+ val desc = action.description
+ try {
+ if (desc != null) {
+ desc.toString().toInt()
+ enableFinish(true)
+ return GuidedAction.ACTION_ID_NEXT
+ }
+ } catch (ex: Exception) {
+ Toast.makeText(requireContext(), "Invalid int: $desc", Toast.LENGTH_SHORT).show()
+ }
+ enableFinish(false)
+ return GuidedAction.ACTION_ID_NEXT
+ }
+ return GuidedAction.ACTION_ID_CURRENT
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= MODIFIER_OFFSET) {
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal?.copy(modifier = newModifier) ?: IntCriterionInput(
+ value = curVal?.value ?: 0,
+ value2 = curVal?.value2 ?: Optional.absent(),
+ modifier = newModifier,
+ )
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ }
+ return true
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ val newInt = findActionById(1L).description?.toString()?.toInt()
+ val modifier = curVal?.modifier ?: CriterionModifier.EQUALS
+ val newValue =
+ if (newInt != null) {
+ IntCriterionInput(value = newInt, modifier = modifier)
+ } else if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
+ IntCriterionInput(value = 0, modifier = modifier)
+ } else {
+ null
+ }
+
+ viewModel.updateFilter(filterOption, newValue)
+ parentFragmentManager.popBackStack()
+ } else if (action.id == GuidedAction.ACTION_ID_CANCEL) {
+ parentFragmentManager.popBackStack()
+ } else if (action.id == ACTION_ID_REMOVE) {
+ viewModel.updateFilter(filterOption, null)
+ parentFragmentManager.popBackStack()
+ }
+ }
+
+ companion object {
+ private const val TAG = "IntPickerFragment"
+ private const val MODIFIER = 2L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/MultiCriterionFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/MultiCriterionFragment.kt
new file mode 100644
index 00000000..9a82b682
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/MultiCriterionFragment.kt
@@ -0,0 +1,163 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.util.Log
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.commit
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.MultiCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.CreateFilterViewModel
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.views.getString
+
+class MultiCriterionFragment(
+ val dataType: DataType,
+ val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal = MultiCriterionInput(modifier = CriterionModifier.INCLUDES_ALL)
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "Click to remove an item",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ curVal =
+ filterOption.getter.invoke(
+ viewModel.objectFilter.value!!,
+ ).getOrNull() ?: MultiCriterionInput(modifier = CriterionModifier.INCLUDES_ALL)
+
+ val modifierOptions =
+ buildList {
+ add(modifierAction(CriterionModifier.INCLUDES))
+ add(modifierAction(CriterionModifier.INCLUDES_ALL))
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curVal.modifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ val includeItems = createItemList(curVal.value.getOrNull().orEmpty())
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(INCLUDE_LIST)
+ .hasNext(false)
+ .title(getString(dataType.pluralStringId))
+ .description("${includeItems.size - 1} ${getString(dataType.pluralStringId)}")
+ .subActions(includeItems)
+ .build(),
+ )
+ // TODO excludes
+
+ addStandardActions(actions, filterOption)
+ }
+
+ private fun createItemList(ids: List): List =
+ buildList {
+ add(
+ GuidedAction.Builder(requireContext())
+ .id(ADD_INCLUDE_ITEM)
+ .title("Add")
+ .build(),
+ )
+ addAll(
+ ids.mapIndexed { index, id ->
+ val nameDesc =
+ viewModel.storedItems[CreateFilterViewModel.DataTypeId(dataType, id)]
+ GuidedAction.Builder(requireContext())
+ .id(INCLUDE_OFFSET + index)
+ .title(nameDesc?.name)
+ .description(nameDesc?.description)
+ .build()
+ }.sortedBy { it.title.toString() },
+ )
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ viewModel.updateFilter(filterOption, curVal)
+ parentFragmentManager.popBackStack()
+ } else {
+ onStandardActionClicked(action, filterOption)
+ }
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= MODIFIER_OFFSET) {
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal.copy(modifier = newModifier)
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ return true
+ } else if (action.id >= EXCLUDE_OFFSET) {
+ TODO()
+ } else if (action.id >= INCLUDE_OFFSET) {
+ val index = action.id - INCLUDE_OFFSET
+ val list = curVal.value.getOrThrow()!!.toMutableList()
+ list.removeAt(index.toInt())
+ curVal = curVal.copy(value = Optional.present(list))
+
+ val action = findActionById(INCLUDE_LIST)
+ action.subActions = createItemList(list)
+ action.description = "${list.size} ${getString(dataType.pluralStringId)}"
+ notifyActionChanged(findActionPositionById(INCLUDE_LIST))
+ return true
+ } else if (action.id == ADD_INCLUDE_ITEM) {
+ requireActivity().supportFragmentManager.commit {
+ addToBackStack("picker")
+ replace(
+ android.R.id.content,
+ SearchPickerFragment(dataType) { newItem ->
+ Log.v(TAG, "Adding ${newItem.id}")
+ viewModel.store(dataType, newItem)
+ val list = curVal.value.getOrNull()?.toMutableList() ?: ArrayList()
+ if (!list.contains(newItem.id)) {
+ list.add(newItem.id)
+ curVal = curVal.copy(value = Optional.present(list))
+ val action = findActionById(INCLUDE_LIST)
+ action.subActions = createItemList(list)
+ action.description =
+ "${list.size} ${getString(dataType.pluralStringId)}"
+ notifyActionChanged(findActionPositionById(INCLUDE_LIST))
+ }
+ },
+ )
+ }
+ } else if (action.id == ADD_EXCLUDE_ITEM) {
+ TODO()
+ }
+ return false
+ }
+
+ companion object {
+ private const val TAG = "HierarchicalMultiCriterionFragment"
+
+ private const val MODIFIER = 1L
+ private const val INCLUDE_LIST = 2L
+ private const val ADD_INCLUDE_ITEM = 3L
+ private const val ADD_EXCLUDE_ITEM = 4L
+ private const val SUBMIT = 5L
+
+ private const val INCLUDE_OFFSET = 1_000_000L
+ private const val EXCLUDE_OFFSET = 2_000_000L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/RatingPickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/RatingPickerFragment.kt
new file mode 100644
index 00000000..ccad4f1c
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/RatingPickerFragment.kt
@@ -0,0 +1,180 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.text.InputType
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.IntCriterionInput
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.views.getString
+
+class RatingPickerFragment(
+ private val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal: IntCriterionInput? = null
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ val curModifier = curVal?.modifier ?: CriterionModifier.EQUALS
+
+ val current =
+ if (viewModel.server.value!!.serverPreferences.ratingsAsStars) {
+ curVal?.value?.div(20.0)
+ } else {
+ curVal?.value?.div(10.0)
+ }
+
+ // TODO show second value for between
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(1L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_criterion_value))
+ .descriptionEditable(true)
+ .descriptionEditInputType(
+ InputType.TYPE_CLASS_NUMBER or
+ InputType.TYPE_NUMBER_FLAG_DECIMAL,
+ )
+ .editDescription(current?.toString())
+ .build(),
+ )
+
+ val modifierOptions =
+ buildList {
+ add(modifierAction(CriterionModifier.EQUALS))
+ add(modifierAction(CriterionModifier.NOT_EQUALS))
+ add(modifierAction(CriterionModifier.GREATER_THAN))
+ add(modifierAction(CriterionModifier.LESS_THAN))
+ add(modifierAction(CriterionModifier.IS_NULL))
+ add(modifierAction(CriterionModifier.NOT_NULL))
+ // TODO: between
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curModifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_FINISH)
+ .hasNext(true)
+ .enabled(false)
+ .title(getString(R.string.stashapp_actions_save))
+ .build(),
+ )
+
+ if (viewModel.getValue(filterOption) != null) {
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(ACTION_ID_REMOVE)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_remove))
+ .build(),
+ )
+ }
+
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(GuidedAction.ACTION_ID_CANCEL)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_actions_cancel))
+ .build(),
+ )
+ }
+
+ override fun onGuidedActionEditedAndProceed(action: GuidedAction): Long {
+ if (action.id == 1L) {
+ val desc = action.description
+ try {
+ if (desc != null) {
+ val newInt = desc.toString().toDouble()
+ val rating100 =
+ if (viewModel.server.value!!.serverPreferences.ratingsAsStars) {
+ (newInt.times(20)).toInt()
+ } else {
+ (newInt.times(10)).toInt()
+ }
+ if (rating100 in 0..100) {
+ enableFinish(true)
+ return GuidedAction.ACTION_ID_NEXT
+ }
+ }
+ } catch (ex: Exception) {
+ Toast.makeText(requireContext(), "Invalid int: $desc", Toast.LENGTH_SHORT).show()
+ }
+ enableFinish(false)
+ return GuidedAction.ACTION_ID_NEXT
+ }
+ return GuidedAction.ACTION_ID_CURRENT
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= MODIFIER_OFFSET) {
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal?.copy(modifier = newModifier) ?: IntCriterionInput(
+ value = curVal?.value ?: 0,
+ value2 = curVal?.value2 ?: Optional.absent(),
+ modifier = newModifier,
+ )
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ }
+ return true
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ val curVal = filterOption.getter(viewModel.objectFilter.value!!)
+ val newInt = findActionById(1L).description?.toString()?.toDouble()
+ val modifier = curVal.getOrNull()?.modifier ?: CriterionModifier.EQUALS
+
+ val rating100 =
+ if (viewModel.server.value!!.serverPreferences.ratingsAsStars) {
+ (newInt?.times(20))?.toInt()
+ } else {
+ (newInt?.times(10))?.toInt()
+ }
+ val newValue =
+ if (rating100 != null) {
+ IntCriterionInput(value = rating100, modifier = modifier)
+ } else if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
+ IntCriterionInput(value = 0, modifier = modifier)
+ } else {
+ null
+ }
+
+ viewModel.updateFilter(filterOption, newValue)
+ parentFragmentManager.popBackStack()
+ } else {
+ onStandardActionClicked(action, filterOption)
+ }
+ }
+
+ companion object {
+ private const val TAG = "StringPickerFragment"
+ private const val MODIFIER = 2L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/SearchPickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/SearchPickerFragment.kt
new file mode 100644
index 00000000..f26dc110
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/SearchPickerFragment.kt
@@ -0,0 +1,293 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.text.TextUtils
+import android.util.Log
+import android.widget.Toast
+import androidx.leanback.app.SearchSupportFragment
+import androidx.leanback.widget.ArrayObjectAdapter
+import androidx.leanback.widget.HeaderItem
+import androidx.leanback.widget.ListRow
+import androidx.leanback.widget.ListRowPresenter
+import androidx.leanback.widget.ObjectAdapter
+import androidx.leanback.widget.Presenter
+import androidx.leanback.widget.Row
+import androidx.leanback.widget.RowPresenter
+import androidx.leanback.widget.SparseArrayObjectAdapter
+import androidx.lifecycle.lifecycleScope
+import androidx.preference.PreferenceManager
+import com.apollographql.apollo.api.Optional
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.StashApplication
+import com.github.damontecres.stashapp.api.fragment.StashData
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.FindFilterType
+import com.github.damontecres.stashapp.api.type.GalleryFilterType
+import com.github.damontecres.stashapp.api.type.SortDirectionEnum
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.data.DataType
+import com.github.damontecres.stashapp.data.room.RecentSearchItem
+import com.github.damontecres.stashapp.presenters.StashPresenter
+import com.github.damontecres.stashapp.util.QueryEngine
+import com.github.damontecres.stashapp.util.StashCoroutineExceptionHandler
+import com.github.damontecres.stashapp.util.StashServer
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.CoroutineContext
+import kotlin.properties.Delegates
+
+/**
+ * Similar to [com.github.damontecres.stashapp.SearchForFragment], search for an item of a specific [DataType]
+ */
+class SearchPickerFragment(
+ private val dataType: DataType,
+ private val addItem: (StashData) -> Unit,
+) : SearchSupportFragment(), SearchSupportFragment.SearchResultProvider {
+ private var taskJob: Job? = null
+ private var query: String? = null
+
+ private val adapter = SparseArrayObjectAdapter(ListRowPresenter())
+ private val searchResultsAdapter = ArrayObjectAdapter(StashPresenter.SELECTOR)
+ private var perPage by Delegates.notNull()
+
+ private val exceptionHandler =
+ CoroutineExceptionHandler { _: CoroutineContext, ex: Throwable ->
+ Log.e(TAG, "Exception in search", ex)
+ Toast.makeText(requireContext(), "Search failed: ${ex.message}", Toast.LENGTH_LONG)
+ .show()
+ }
+
+ private val server = StashServer.requireCurrentServer()
+ private val queryEngine = QueryEngine(server)
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ perPage =
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .getInt("maxSearchResults", 25)
+ title =
+ requireActivity().intent.getStringExtra(TITLE_KEY) ?: getString(dataType.pluralStringId)
+ searchResultsAdapter.presenterSelector = StashPresenter.SELECTOR
+ adapter.set(
+ RESULTS_POS,
+ ListRow(HeaderItem(getString(R.string.waiting_for_query)), ArrayObjectAdapter()),
+ )
+
+ setSearchResultProvider(this)
+ setOnItemViewClickedListener {
+ itemViewHolder: Presenter.ViewHolder,
+ item: Any,
+ rowViewHolder: RowPresenter.ViewHolder?,
+ row: Row?,
+ ->
+ if (item is StashData) {
+ returnId(item)
+ } else {
+ throw IllegalStateException("Unknown item: $item")
+ }
+ }
+ }
+
+ private fun returnId(item: StashData) {
+ val currentServer = StashServer.getCurrentStashServer(requireContext())
+ if (dataType in DATA_TYPE_SUGGESTIONS && currentServer != null) {
+ viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO + StashCoroutineExceptionHandler()) {
+ StashApplication.getDatabase().recentSearchItemsDao()
+ .insert(RecentSearchItem(currentServer.url, item.id, dataType))
+ }
+ }
+ addItem(item)
+ parentFragmentManager.popBackStackImmediate()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (dataType in DATA_TYPE_SUGGESTIONS) {
+ viewLifecycleOwner.lifecycleScope.launch(
+ StashCoroutineExceptionHandler {
+ Toast.makeText(
+ requireContext(),
+ "Error loading suggestions: ${it.message}",
+ Toast.LENGTH_LONG,
+ )
+ },
+ ) {
+ val resultsAdapter = ArrayObjectAdapter(StashPresenter.SELECTOR)
+ val sortBy =
+ when (dataType) {
+ DataType.GALLERY -> "images_count"
+ else -> "scenes_count"
+ }
+ val filter =
+ FindFilterType(
+ direction = Optional.present(SortDirectionEnum.DESC),
+ per_page = Optional.present(perPage),
+ sort = Optional.present(sortBy),
+ )
+ val results =
+ when (dataType) {
+ DataType.GALLERY ->
+ // Cannot add an image to a zip/folder gallery, so exclude them
+ queryEngine.findGalleries(
+ filter,
+ GalleryFilterType(
+ path =
+ Optional.present(
+ StringCriterionInput(
+ value = "",
+ modifier = CriterionModifier.IS_NULL,
+ ),
+ ),
+ ),
+ )
+
+ else -> queryEngine.find(dataType, filter)
+ }
+ resultsAdapter.addAll(0, results)
+ adapter.set(
+ SUGGESTIONS_POS,
+ ListRow(HeaderItem(getString(R.string.suggestions)), resultsAdapter),
+ )
+ }
+ viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO + StashCoroutineExceptionHandler()) {
+ val currentServer = StashServer.getCurrentStashServer(requireContext())
+ if (currentServer != null) {
+ val mostRecentIds =
+ StashApplication.getDatabase().recentSearchItemsDao()
+ .getMostRecent(perPage, currentServer.url, dataType).map { it.id }
+ Log.v(TAG, "Got ${mostRecentIds.size} recent items")
+ if (mostRecentIds.isNotEmpty()) {
+ val items = queryEngine.getByIds(dataType, mostRecentIds)
+ val results = ArrayObjectAdapter(StashPresenter.SELECTOR)
+ if (items.isNotEmpty()) {
+ Log.v(
+ TAG,
+ "${mostRecentIds.size} recent items resolved to ${results.size()} items",
+ )
+ results.addAll(0, items)
+ withContext(Dispatchers.Main) {
+ val headerName =
+ getString(
+ R.string.format_recently_used,
+ getString(dataType.pluralStringId).lowercase(),
+ )
+ adapter.set(RECENT_POS, ListRow(HeaderItem(headerName), results))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ override fun getResultsAdapter(): ObjectAdapter {
+ return adapter
+ }
+
+ override fun onQueryTextChange(newQuery: String): Boolean {
+ taskJob?.cancel()
+ taskJob =
+ viewLifecycleOwner.lifecycleScope.launch(exceptionHandler) {
+ val searchDelay =
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .getInt("searchDelay", 500)
+ delay(searchDelay.toLong())
+ search(newQuery)
+ }
+ return true
+ }
+
+ override fun onQueryTextSubmit(query: String): Boolean {
+ taskJob?.cancel()
+ taskJob =
+ viewLifecycleOwner.lifecycleScope.launch(exceptionHandler) {
+ search(query)
+ }
+ return true
+ }
+
+ private suspend fun search(query: String) {
+ searchResultsAdapter.clear()
+
+ this.query = query
+ if (!TextUtils.isEmpty(query)) {
+ adapter.set(
+ RESULTS_POS,
+ ListRow(
+ HeaderItem(getString(R.string.stashapp_loading_generic)),
+ ArrayObjectAdapter(),
+ ),
+ )
+ val filter =
+ FindFilterType(
+ q = Optional.present(query),
+ per_page = Optional.present(perPage),
+ )
+ viewLifecycleOwner.lifecycleScope.launch(exceptionHandler) {
+ val results =
+ when (dataType) {
+ DataType.GALLERY ->
+ // Cannot add an image to a zip gallery, so exclude them
+ queryEngine.findGalleries(
+ filter,
+ GalleryFilterType(
+ path =
+ Optional.present(
+ StringCriterionInput(
+ value = "",
+ modifier = CriterionModifier.IS_NULL,
+ ),
+ ),
+ ),
+ )
+
+ else -> queryEngine.find(dataType, filter)
+ }
+ if (results.isNotEmpty()) {
+ searchResultsAdapter.addAll(0, results)
+ adapter.set(
+ RESULTS_POS,
+ ListRow(HeaderItem(getString(R.string.results)), searchResultsAdapter),
+ )
+ } else {
+ adapter.set(
+ RESULTS_POS,
+ ListRow(
+ HeaderItem(getString(R.string.stashapp_component_tagger_results_match_failed_no_result)),
+ ArrayObjectAdapter(),
+ ),
+ )
+ }
+ }
+ } else {
+ adapter.set(
+ RESULTS_POS,
+ ListRow(HeaderItem(getString(R.string.waiting_for_query)), ArrayObjectAdapter()),
+ )
+ }
+ }
+
+ companion object {
+ const val TAG = "SearchForFragment"
+
+ const val TITLE_KEY = "title"
+
+ private const val RESULTS_POS = 0
+ private const val SUGGESTIONS_POS = RESULTS_POS + 1
+ private const val RECENT_POS = SUGGESTIONS_POS + 1
+
+ // List of data types that support querying for suggestions
+ val DATA_TYPE_SUGGESTIONS =
+ setOf(
+ DataType.TAG,
+ DataType.PERFORMER,
+ DataType.STUDIO,
+ DataType.GALLERY,
+ DataType.MOVIE,
+ )
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/filter/picker/StringPickerFragment.kt b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/StringPickerFragment.kt
new file mode 100644
index 00000000..d3d26a98
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/stashapp/filter/picker/StringPickerFragment.kt
@@ -0,0 +1,111 @@
+package com.github.damontecres.stashapp.filter.picker
+
+import android.os.Bundle
+import android.text.InputType
+import androidx.core.content.ContextCompat
+import androidx.leanback.widget.GuidanceStylist
+import androidx.leanback.widget.GuidedAction
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
+import com.github.damontecres.stashapp.api.type.StashDataFilter
+import com.github.damontecres.stashapp.api.type.StringCriterionInput
+import com.github.damontecres.stashapp.filter.CreateFilterGuidedStepFragment
+import com.github.damontecres.stashapp.filter.FilterOption
+import com.github.damontecres.stashapp.util.isNotNullOrBlank
+import com.github.damontecres.stashapp.views.getString
+
+class StringPickerFragment(
+ private val filterOption: FilterOption,
+) : CreateFilterGuidedStepFragment() {
+ private var curVal: StringCriterionInput? = null
+
+ override fun onCreateGuidance(savedInstanceState: Bundle?): GuidanceStylist.Guidance {
+ return GuidanceStylist.Guidance(
+ getString(filterOption.nameStringId),
+ "",
+ null,
+ ContextCompat.getDrawable(requireContext(), R.mipmap.stash_logo),
+ )
+ }
+
+ override fun onCreateActions(
+ actions: MutableList,
+ savedInstanceState: Bundle?,
+ ) {
+ curVal = filterOption.getter(viewModel.objectFilter.value!!).getOrNull()
+ val currentString = curVal?.value
+ val curModifier = curVal?.modifier ?: CriterionModifier.EQUALS
+
+ // TODO disable for is/not null modifier
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(1L)
+ .hasNext(true)
+ .title(getString(R.string.stashapp_criterion_value))
+ .descriptionEditable(true)
+ .descriptionEditInputType(InputType.TYPE_CLASS_TEXT)
+ .editDescription(currentString)
+ .build(),
+ )
+
+ val modifierOptions =
+ buildList {
+ add(modifierAction(CriterionModifier.EQUALS))
+ add(modifierAction(CriterionModifier.NOT_EQUALS))
+ add(modifierAction(CriterionModifier.INCLUDES))
+ add(modifierAction(CriterionModifier.EXCLUDES))
+ add(modifierAction(CriterionModifier.IS_NULL))
+ add(modifierAction(CriterionModifier.NOT_NULL))
+ // TODO: regex?
+ }
+ actions.add(
+ GuidedAction.Builder(requireContext())
+ .id(MODIFIER)
+ .hasNext(false)
+ .title("Modifier")
+ .description(curModifier.getString(requireContext()))
+ .subActions(modifierOptions)
+ .build(),
+ )
+
+ addStandardActions(actions, filterOption)
+ }
+
+ override fun onSubGuidedActionClicked(action: GuidedAction): Boolean {
+ if (action.id >= MODIFIER_OFFSET) {
+ val newModifier = CriterionModifier.entries[(action.id - MODIFIER_OFFSET).toInt()]
+ curVal = curVal?.copy(modifier = newModifier) ?: StringCriterionInput(
+ value = "",
+ modifier = newModifier,
+ )
+ findActionById(MODIFIER).description = newModifier.getString(requireContext())
+ notifyActionChanged(findActionPositionById(MODIFIER))
+ }
+ return true
+ }
+
+ override fun onGuidedActionClicked(action: GuidedAction) {
+ if (action.id == GuidedAction.ACTION_ID_FINISH) {
+ val newString = findActionById(1L).description?.toString()
+ val modifier = curVal?.modifier ?: CriterionModifier.EQUALS
+ val newValue =
+ if (newString.isNotNullOrBlank()) {
+ StringCriterionInput(value = newString, modifier = modifier)
+ } else if (modifier == CriterionModifier.IS_NULL || modifier == CriterionModifier.NOT_NULL) {
+ StringCriterionInput(value = "", modifier = modifier)
+ } else {
+ null
+ }
+
+ viewModel.updateFilter(filterOption, newValue)
+ parentFragmentManager.popBackStack()
+ } else {
+ onStandardActionClicked(action, filterOption)
+ }
+ }
+
+ companion object {
+ private const val TAG = "StringPickerFragment"
+ private const val MODIFIER = 2L
+ }
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/suppliers/MovieDataSupplier.kt b/app/src/main/java/com/github/damontecres/stashapp/suppliers/MovieDataSupplier.kt
index b3ad1457..6ce056d7 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/suppliers/MovieDataSupplier.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/suppliers/MovieDataSupplier.kt
@@ -24,6 +24,7 @@ class MovieDataSupplier(
return FindMoviesQuery(
filter = filter,
movie_filter = movieFilter,
+ ids = null,
)
}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/Constants.kt b/app/src/main/java/com/github/damontecres/stashapp/util/Constants.kt
index 75d857ad..58549178 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/util/Constants.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/util/Constants.kt
@@ -10,8 +10,8 @@ import android.util.DisplayMetrics
import android.util.Log
import android.view.View
import android.widget.Adapter
-import android.widget.ArrayAdapter
import android.widget.FrameLayout
+import android.widget.ListAdapter
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
@@ -26,8 +26,11 @@ import com.apollographql.apollo.exception.ApolloException
import com.apollographql.apollo.exception.ApolloHttpException
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.LazyHeaders
+import com.chrynan.parcelable.core.getParcelableExtra
import com.chrynan.parcelable.core.putExtra
import com.github.damontecres.stashapp.ImageActivity
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.StashApplication
import com.github.damontecres.stashapp.api.ServerInfoQuery
import com.github.damontecres.stashapp.api.fragment.FullSceneData
import com.github.damontecres.stashapp.api.fragment.GalleryData
@@ -628,7 +631,7 @@ fun View.animateToInvisible(
*/
fun getMaxMeasuredWidth(
context: Context,
- adapter: ArrayAdapter,
+ adapter: ListAdapter,
maxWidth: Int? = null,
maxWidthFraction: Double? = 0.4,
): Int {
@@ -643,9 +646,6 @@ fun getMaxMeasuredWidth(
Log.w(Constants.TAG, "maxWidthFraction is not null, but couldn't get window size")
Int.MAX_VALUE
}
- if (adapter.viewTypeCount != 1) {
- throw IllegalStateException("Adapter creates more than 1 type of view")
- }
val tempParent = FrameLayout(context)
var maxMeasuredWidth = 0
@@ -747,8 +747,8 @@ fun CoroutineScope.launchIO(
}
}
-fun Intent.putDataType(dataType: DataType) {
- this.putExtra("dataType", dataType.name)
+fun Intent.putDataType(dataType: DataType): Intent {
+ return this.putExtra("dataType", dataType.name)
}
fun Intent.getDataType(): DataType {
@@ -762,3 +762,13 @@ fun Intent.putFilterArgs(
): Intent {
return putExtra(name, filterArgs, parcelable)
}
+
+fun Intent.getFilterArgs(name: String): FilterArgs? {
+ return getParcelableExtra(name, FilterArgs::class, 0, parcelable)
+}
+
+fun experimentalFeaturesEnabled(): Boolean {
+ val context = StashApplication.getApplication()
+ return PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.pref_key_experimental_features), false)
+}
diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/MutationEngine.kt b/app/src/main/java/com/github/damontecres/stashapp/util/MutationEngine.kt
index 38fabaf7..d5f1227c 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/util/MutationEngine.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/util/MutationEngine.kt
@@ -15,6 +15,7 @@ import com.github.damontecres.stashapp.api.ImageResetOMutation
import com.github.damontecres.stashapp.api.InstallPackagesMutation
import com.github.damontecres.stashapp.api.MetadataGenerateMutation
import com.github.damontecres.stashapp.api.MetadataScanMutation
+import com.github.damontecres.stashapp.api.SaveFilterMutation
import com.github.damontecres.stashapp.api.SceneAddOMutation
import com.github.damontecres.stashapp.api.SceneAddPlayCountMutation
import com.github.damontecres.stashapp.api.SceneDeleteOMutation
@@ -27,6 +28,7 @@ import com.github.damontecres.stashapp.api.UpdateMarkerMutation
import com.github.damontecres.stashapp.api.UpdatePerformerMutation
import com.github.damontecres.stashapp.api.fragment.MarkerData
import com.github.damontecres.stashapp.api.fragment.PerformerData
+import com.github.damontecres.stashapp.api.fragment.SavedFilterData
import com.github.damontecres.stashapp.api.fragment.TagData
import com.github.damontecres.stashapp.api.type.GalleryUpdateInput
import com.github.damontecres.stashapp.api.type.GenerateMetadataInput
@@ -35,6 +37,7 @@ import com.github.damontecres.stashapp.api.type.PackageSpecInput
import com.github.damontecres.stashapp.api.type.PackageType
import com.github.damontecres.stashapp.api.type.PerformerCreateInput
import com.github.damontecres.stashapp.api.type.PerformerUpdateInput
+import com.github.damontecres.stashapp.api.type.SaveFilterInput
import com.github.damontecres.stashapp.api.type.ScanMetadataInput
import com.github.damontecres.stashapp.api.type.SceneMarkerCreateInput
import com.github.damontecres.stashapp.api.type.SceneMarkerUpdateInput
@@ -385,6 +388,11 @@ class MutationEngine(
return result.data!!.installPackages
}
+ suspend fun saveFilter(input: SaveFilterInput): SavedFilterData {
+ val mutation = SaveFilterMutation(input)
+ return executeMutation(mutation).data!!.saveFilter.savedFilterData
+ }
+
companion object {
const val TAG = "MutationEngine"
diff --git a/app/src/main/java/com/github/damontecres/stashapp/util/QueryEngine.kt b/app/src/main/java/com/github/damontecres/stashapp/util/QueryEngine.kt
index 4448ba06..a4165e4f 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/util/QueryEngine.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/util/QueryEngine.kt
@@ -34,6 +34,7 @@ import com.github.damontecres.stashapp.api.fragment.MovieData
import com.github.damontecres.stashapp.api.fragment.PerformerData
import com.github.damontecres.stashapp.api.fragment.SavedFilterData
import com.github.damontecres.stashapp.api.fragment.SlimSceneData
+import com.github.damontecres.stashapp.api.fragment.StashData
import com.github.damontecres.stashapp.api.fragment.StudioData
import com.github.damontecres.stashapp.api.fragment.TagData
import com.github.damontecres.stashapp.api.type.FindFilterType
@@ -92,6 +93,7 @@ class QueryEngine(
suspend fun findScenes(
findFilter: FindFilterType? = null,
sceneFilter: SceneFilterType? = null,
+ ids: List? = null,
useRandom: Boolean = true,
): List {
val query =
@@ -99,7 +101,7 @@ class QueryEngine(
FindScenesQuery(
filter = updateFilter(findFilter, useRandom),
scene_filter = sceneFilter,
- ids = null,
+ ids = ids,
),
)
val scenes =
@@ -198,6 +200,7 @@ class QueryEngine(
suspend fun findMovies(
findFilter: FindFilterType? = null,
movieFilter: MovieFilterType? = null,
+ movieIds: List? = null,
useRandom: Boolean = true,
): List {
val query =
@@ -205,6 +208,7 @@ class QueryEngine(
FindMoviesQuery(
filter = updateFilter(findFilter, useRandom),
movie_filter = movieFilter,
+ ids = movieIds,
),
)
val tags = executeQuery(query).data?.findMovies?.movies?.map { it.movieData }
@@ -304,6 +308,28 @@ class QueryEngine(
}
}
+ /**
+ * Search for a type of data with the given query. Users will need to cast the returned List.
+ */
+ suspend fun getByIds(
+ type: DataType,
+ ids: List,
+ ): List {
+ if (ids.isEmpty()) {
+ return emptyList()
+ }
+ return when (type) {
+ DataType.SCENE -> findScenes(ids = ids)
+ DataType.PERFORMER -> findPerformers(performerIds = ids)
+ DataType.TAG -> getTags(ids)
+ DataType.STUDIO -> findStudios(studioIds = ids)
+ DataType.MOVIE -> findMovies(movieIds = ids)
+ DataType.MARKER -> TODO()
+ DataType.IMAGE -> TODO()
+ DataType.GALLERY -> TODO()
+ }
+ }
+
suspend fun getSavedFilter(filterId: String): SavedFilterData? {
val query = FindSavedFilterQuery(filterId)
return executeQuery(query).data?.findSavedFilter?.savedFilterData
diff --git a/app/src/main/java/com/github/damontecres/stashapp/views/Formatting.kt b/app/src/main/java/com/github/damontecres/stashapp/views/Formatting.kt
index e2930a08..862bbc34 100644
--- a/app/src/main/java/com/github/damontecres/stashapp/views/Formatting.kt
+++ b/app/src/main/java/com/github/damontecres/stashapp/views/Formatting.kt
@@ -1,6 +1,9 @@
package com.github.damontecres.stashapp.views
+import android.content.Context
import android.os.Build
+import com.github.damontecres.stashapp.R
+import com.github.damontecres.stashapp.api.type.CriterionModifier
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
@@ -50,3 +53,21 @@ fun parseTimeToString(ts: Any?): String? {
val String.fileNameFromPath
get() = this.replace(Regex("""^.*[\\/]"""), "")
+
+fun CriterionModifier.getString(context: Context): String =
+ when (this) {
+ CriterionModifier.EQUALS -> context.getString(R.string.stashapp_criterion_modifier_equals)
+ CriterionModifier.NOT_EQUALS -> context.getString(R.string.stashapp_criterion_modifier_not_equals)
+ CriterionModifier.LESS_THAN -> context.getString(R.string.stashapp_criterion_modifier_less_than)
+ CriterionModifier.GREATER_THAN -> context.getString(R.string.stashapp_criterion_modifier_greater_than)
+ CriterionModifier.IS_NULL -> context.getString(R.string.stashapp_criterion_modifier_is_null)
+ CriterionModifier.NOT_NULL -> context.getString(R.string.stashapp_criterion_modifier_not_null)
+ CriterionModifier.INCLUDES_ALL -> context.getString(R.string.stashapp_criterion_modifier_includes_all)
+ CriterionModifier.INCLUDES -> context.getString(R.string.stashapp_criterion_modifier_includes)
+ CriterionModifier.EXCLUDES -> context.getString(R.string.stashapp_criterion_modifier_excludes)
+ CriterionModifier.MATCHES_REGEX -> context.getString(R.string.stashapp_criterion_modifier_matches_regex)
+ CriterionModifier.NOT_MATCHES_REGEX -> context.getString(R.string.stashapp_criterion_modifier_not_matches_regex)
+ CriterionModifier.BETWEEN -> context.getString(R.string.stashapp_criterion_modifier_between)
+ CriterionModifier.NOT_BETWEEN -> context.getString(R.string.stashapp_criterion_modifier_not_between)
+ CriterionModifier.UNKNOWN__ -> "Unknown"
+ }
diff --git a/app/src/main/res/layout/popup_header.xml b/app/src/main/res/layout/popup_header.xml
new file mode 100644
index 00000000..ada439d0
--- /dev/null
+++ b/app/src/main/res/layout/popup_header.xml
@@ -0,0 +1,17 @@
+
diff --git a/app/src/test/java/com/github/damontecres/stashapp/FrontPageFilterTests.kt b/app/src/test/java/com/github/damontecres/stashapp/FrontPageFilterTests.kt
index 6cdafd00..eaab04e5 100644
--- a/app/src/test/java/com/github/damontecres/stashapp/FrontPageFilterTests.kt
+++ b/app/src/test/java/com/github/damontecres/stashapp/FrontPageFilterTests.kt
@@ -65,7 +65,14 @@ class FrontPageFilterTests {
"2",
FilterMode.PERFORMERS,
)
- onBlocking { findScenes(anyOrNull(), anyOrNull(), any()) } doReturn listOf()
+ onBlocking {
+ findScenes(
+ anyOrNull(),
+ anyOrNull(),
+ anyOrNull(),
+ any(),
+ )
+ } doReturn listOf()
onBlocking {
findPerformers(
anyOrNull(),