From 6094a520e2aab283d26b6637cd994b807a7b1f61 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 29 Nov 2025 15:56:18 -0800 Subject: [PATCH] support custom smart playlist tags --- src/i18n/locales/en.json | 12 +- .../components/query-builder/index.tsx | 9 +- .../query-builder/query-builder-option.tsx | 9 +- .../components/playlist-query-builder.tsx | 52 +++++- .../components/general/general-tab.tsx | 34 ++-- .../general/query-builder-settings.tsx | 151 ++++++++++++++++++ src/renderer/store/settings.store.ts | 26 +++ 7 files changed, 278 insertions(+), 15 deletions(-) create mode 100644 src/renderer/features/settings/components/general/query-builder-settings.tsx diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 736a1a353..e74edf60c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -481,6 +481,7 @@ "updates": "update", "cache": "cache", "application": "application", + "queryBuilder": "query builder", "theme": "theme", "controls": "controls", "sidebar": "sidebar", @@ -553,6 +554,10 @@ "pause": "pause", "viewQueue": "view queue" }, + "queryBuilder": { + "standardTags": "standard tags", + "customTags": "custom tags" + }, "releaseType": { "primary": { "album": "$t(entity.album_one)", @@ -856,7 +861,12 @@ "windowBarStyle_description": "select the style of the window bar", "windowBarStyle": "window bar style", "zoom_description": "sets the zoom percentage for the application", - "zoom": "zoom percentage" + "zoom": "zoom percentage", + "queryBuilder": "query builder", + "queryBuilderCustomFields_inputLabel": "label", + "queryBuilderCustomFields_inputTag": "tag", + "queryBuilderCustomFields": "custom fields", + "queryBuilderCustomFields_description": "add custom fields to use in query builders" }, "table": { "column": { diff --git a/src/renderer/components/query-builder/index.tsx b/src/renderer/components/query-builder/index.tsx index f774d06e4..d9c0a6ed7 100644 --- a/src/renderer/components/query-builder/index.tsx +++ b/src/renderer/components/query-builder/index.tsx @@ -11,19 +11,24 @@ import { Select } from '/@/shared/components/select/select'; import { Stack } from '/@/shared/components/stack/stack'; import { QueryBuilderGroup, QueryBuilderRule } from '/@/shared/types/types'; +export type FilterGroup = { group: string; items: FilterItem[] }; + +export type FilterItem = { label: string; type: string; value: string }; + +export type Filters = FilterGroup[] | FilterItem[]; type AddArgs = { groupIndex: number[]; level: number; }; - type DeleteArgs = { groupIndex: number[]; level: number; uniqueId: string; }; + interface QueryBuilderProps { data: Record; - filters: { label: string; type: string; value: string }[]; + filters: Filters; groupIndex: number[]; level: number; onAddRule: (args: AddArgs) => void; diff --git a/src/renderer/components/query-builder/query-builder-option.tsx b/src/renderer/components/query-builder/query-builder-option.tsx index fb48c1719..1ed8ff66f 100644 --- a/src/renderer/components/query-builder/query-builder-option.tsx +++ b/src/renderer/components/query-builder/query-builder-option.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { Filters } from '/@/renderer/components/query-builder'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; @@ -15,7 +16,7 @@ type DeleteArgs = { interface QueryOptionProps { data: QueryBuilderRule; - filters: { label: string; type: string; value: string }[]; + filters: Filters; groupIndex: number[]; level: number; noRemove: boolean; @@ -165,7 +166,11 @@ export const QueryBuilderOption = ({ }); }; - const fieldType = filters.find((f) => f.value === field)?.type; + // Handle both grouped and flat filter data + const flatFilters = filters.some((f: any) => f.group && f.items) + ? filters.flatMap((group: any) => group.items || []) + : filters; + const fieldType = flatFilters.find((f: any) => f.value === field)?.type; const operatorsByFieldType = operators[fieldType as keyof typeof operators]; const ml = 20; diff --git a/src/renderer/features/playlists/components/playlist-query-builder.tsx b/src/renderer/features/playlists/components/playlist-query-builder.tsx index abc3e7b63..217c74883 100644 --- a/src/renderer/features/playlists/components/playlist-query-builder.tsx +++ b/src/renderer/features/playlists/components/playlist-query-builder.tsx @@ -18,6 +18,7 @@ import { QueryBuilder } from '/@/renderer/components/query-builder'; import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api'; import { convertNDQueryToQueryGroup } from '/@/renderer/features/playlists/utils'; import { useCurrentServer } from '/@/renderer/store'; +import { useQueryBuilderSettings } from '/@/renderer/store/settings.store'; import { NDSongQueryBooleanOperators, NDSongQueryDateOperators, @@ -168,6 +169,7 @@ export const PlaylistQueryBuilder = forwardRef( ) => { const { t } = useTranslation(); const server = useCurrentServer(); + const queryBuilderSettings = useQueryBuilderSettings(); // Memoize initial filters to avoid recalculation const initialFilters = useMemo( @@ -412,6 +414,54 @@ export const PlaylistQueryBuilder = forwardRef( }); }, []); + const customFields = useMemo(() => { + return queryBuilderSettings.tag + .filter((field) => field.value && field.value.trim() !== '') + .map((field) => ({ + label: field.label, + type: field.type, + value: field.value, + })); + }, [queryBuilderSettings.tag]); + + const groupedFilters = useMemo(() => { + type FilterGroup = { + group: string; + items: Array<{ label: string; type: string; value: string }>; + }; + const groups: FilterGroup[] = []; + + // Custom Fields group + if (customFields.length > 0) { + groups.push({ + group: t('queryBuilder.customTags', { + postProcess: 'titleCase', + }), + items: customFields, + }); + } + + // Standard Fields group + if (NDSongQueryFields.length > 0) { + groups.push({ + group: t('queryBuilder.standardTags', { + postProcess: 'titleCase', + }), + items: NDSongQueryFields, + }); + } + + if (groups.length === 0) { + return NDSongQueryFields; + } + + if (groups.length === 1) { + return groups[0].items; + } + + return groups; + }, [customFields, t]); + // Memoize sort options const sortOptions = useMemo( () => [ @@ -483,7 +533,7 @@ export const PlaylistQueryBuilder = forwardRef( { + const server = useCurrentServer(); + const supportsSmartPlaylists = hasFeature(server, ServerFeature.PLAYLISTS_SMART); + + const sections = useMemo(() => { + const baseSections = [ + { component: ApplicationSettings, key: 'application' }, + { component: ThemeSettings, key: 'theme' }, + { component: ControlSettings, key: 'control' }, + { component: SidebarSettings, key: 'sidebar' }, + { component: ScrobbleSettings, key: 'scrobble' }, + { component: LyricSettings, key: 'lyrics' }, + ]; + + if (supportsSmartPlaylists) { + baseSections.push({ component: QueryBuilderSettings, key: 'queryBuilder' }); + } + + return baseSections; + }, [supportsSmartPlaylists]); + return ( {sections.map(({ component: Section, key }, index) => ( diff --git a/src/renderer/features/settings/components/general/query-builder-settings.tsx b/src/renderer/features/settings/components/general/query-builder-settings.tsx new file mode 100644 index 000000000..024a1f7ce --- /dev/null +++ b/src/renderer/features/settings/components/general/query-builder-settings.tsx @@ -0,0 +1,151 @@ +import { useTranslation } from 'react-i18next'; + +import { + SettingOption, + SettingsSection, +} from '/@/renderer/features/settings/components/settings-section'; +import { useQueryBuilderSettings, useSettingsStoreActions } from '/@/renderer/store/settings.store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Button } from '/@/shared/components/button/button'; +import { Group } from '/@/shared/components/group/group'; +import { Select } from '/@/shared/components/select/select'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextInput } from '/@/shared/components/text-input/text-input'; + +const QUERY_VALUE_INPUT_TYPES = [ + { label: 'Boolean', value: 'boolean' }, + { label: 'Date', value: 'date' }, + { label: 'Date Range', value: 'dateRange' }, + { label: 'Number', value: 'number' }, + { label: 'Playlist', value: 'playlist' }, + { label: 'String', value: 'string' }, +] as const; + +export const QueryBuilderSettings = () => { + const { t } = useTranslation(); + const queryBuilder = useQueryBuilderSettings(); + const { setSettings } = useSettingsStoreActions(); + + const handleAddCustomField = () => { + setSettings({ + queryBuilder: { + tag: [ + ...queryBuilder.tag, + { + label: '', + type: 'string', + value: '', + }, + ], + }, + }); + }; + + const handleRemoveCustomField = (index: number) => { + setSettings({ + queryBuilder: { + tag: queryBuilder.tag.filter((_, i) => i !== index), + }, + }); + }; + + const handleUpdateCustomField = ( + index: number, + field: 'label' | 'type' | 'value', + newValue: string, + ) => { + setSettings({ + queryBuilder: { + tag: queryBuilder.tag.map((item, i) => + i === index ? { ...item, [field]: newValue } : item, + ), + }, + }); + }; + + const customFieldsOptions: SettingOption[] = [ + { + control: ( + + {queryBuilder.tag.length > 0 && ( + + {queryBuilder.tag.map((field, index) => ( + + + handleUpdateCustomField( + index, + 'label', + e.currentTarget.value, + ) + } + placeholder={t( + 'setting.queryBuilderCustomFields_inputLabel', + { + postProcess: 'sentenceCase', + }, + )} + value={field.label} + width="30%" + /> +