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(),