import type { UseSuspenseQueryResult } from '@tanstack/react-query'; import { closeAllModals, openModal } from '@mantine/modals'; import { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PlaylistQueryBuilder, PlaylistQueryBuilderRef, } from '/@/renderer/features/playlists/components/playlist-query-builder'; import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation'; import { convertQueryGroupToNDQuery } from '/@/renderer/features/playlists/utils'; import { JsonPreview } from '/@/renderer/features/shared/components/json-preview'; import { Box } from '/@/shared/components/box/box'; import { Button } from '/@/shared/components/button/button'; import { Flex } from '/@/shared/components/flex/flex'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; import { JsonInput } from '/@/shared/components/json-input/json-input'; import { ConfirmModal } from '/@/shared/components/modal/modal'; import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Stack } from '/@/shared/components/stack/stack'; import { Text } from '/@/shared/components/text/text'; import { toast } from '/@/shared/components/toast/toast'; import { SongListSort } from '/@/shared/types/domain-types'; export interface PlaylistQueryEditorProps { detailQuery: UseSuspenseQueryResult; handleSave: ( filter: Record, extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string; }, ) => void; handleSaveAs: ( filter: Record, extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string; }, ) => void; isQueryBuilderExpanded: boolean; onToggleExpand: () => void; playlistId: string; queryBuilderRef: React.RefObject; updatePlaylistMutation: ReturnType; } type AppliedJsonState = { limit?: number; limitPercent?: number; query: Record; sort?: string; }; type EditorMode = 'builder' | 'json'; const serializeFiltersToRulesJson = (filters: { extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] }; filters: any; }): Record => { const queryValue = convertQueryGroupToNDQuery(filters.filters); const sortString = filters.extraFilters.sortBy?.[0]; return { ...queryValue, ...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }), ...(filters.extraFilters.limitPercent != null && { limitPercent: filters.extraFilters.limitPercent, }), ...(sortString && { sort: sortString }), }; }; const parseRulesJsonToSaveArgs = ( parsed: Record, ): { extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] }; filter: Record; } => { const rootKey = parsed.all ? 'all' : 'any'; const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] }; return { extraFilters: { ...(parsed.limit != null && { limit: parsed.limit }), ...(parsed.limitPercent != null && { limitPercent: parsed.limitPercent }), ...(parsed.sort != null && { sortBy: [parsed.sort] }), }, filter, }; }; export const PlaylistQueryEditor = ({ detailQuery, handleSave, handleSaveAs, isQueryBuilderExpanded, onToggleExpand, playlistId, queryBuilderRef, updatePlaylistMutation, }: PlaylistQueryEditorProps) => { const { t } = useTranslation(); const [editorMode, setEditorMode] = useState('builder'); const [jsonText, setJsonText] = useState(''); const [appliedJsonState, setAppliedJsonState] = useState(null); const getFiltersForSave = useCallback((): null | { extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[]; sortOrder?: string; }; filter: Record; } => { if (editorMode === 'json') { try { const parsed = JSON.parse(jsonText) as Record; const { extraFilters, filter } = parseRulesJsonToSaveArgs(parsed); return { extraFilters, filter }; } catch { return null; } } const filters = queryBuilderRef.current?.getFilters(); if (!filters) return null; return { extraFilters: filters.extraFilters, filter: convertQueryGroupToNDQuery(filters.filters), }; }, [editorMode, jsonText, queryBuilderRef]); const openPreviewModal = useCallback(() => { const payload = getFiltersForSave(); if (!payload) { if (editorMode === 'json') { toast.error({ message: t('error.invalidJson') }); } return; } const previewValue = { ...payload.filter, ...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }), ...(payload.extraFilters.limitPercent != null && { limitPercent: payload.extraFilters.limitPercent, }), ...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }), }; openModal({ children: , size: 'xl', title: t('common.preview'), }); }, [editorMode, getFiltersForSave, t]); const openSaveAndReplaceModal = useCallback(() => { if (!isQueryBuilderExpanded) return; const payload = getFiltersForSave(); if (!payload) { if (editorMode === 'json') { toast.error({ message: t('error.invalidJson') }); } return; } openModal({ children: ( { handleSave(payload.filter, payload.extraFilters); closeAllModals(); }} > {t('common.areYouSure')} ), title: t('common.saveAndReplace'), }); }, [editorMode, getFiltersForSave, handleSave, isQueryBuilderExpanded, t]); const parseSortBy = useCallback((): string[] => { const sort = detailQuery?.data?.rules?.sort; // Handle new syntax: comma-separated with +/- prefix // e.g., "+album,-year" -> return as single string in array if (typeof sort === 'string') { // Check if it's new syntax (has +/- prefix or commas) if (sort.includes(',') || sort.startsWith('+') || sort.startsWith('-')) { return [sort]; } // Old syntax: single field, convert to new format with default order const order = detailQuery?.data?.rules?.order || 'asc'; const prefix = order === 'desc' ? '-' : '+'; return [`${prefix}${sort}`]; } if (Array.isArray(sort)) { // If array, check if first item has +/- prefix if ( sort.length > 0 && typeof sort[0] === 'string' && (sort[0].startsWith('+') || sort[0].startsWith('-')) ) { return sort; } // Old array format, convert to new format const order = detailQuery?.data?.rules?.order || 'asc'; const prefix = order === 'desc' ? '-' : '+'; return sort.map((s) => `${prefix}${s}`); } return ['+dateAdded']; }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); const parseSortOrder = useCallback((): 'asc' | 'desc' => { const sort = detailQuery?.data?.rules?.sort; if (typeof sort === 'string' && sort.startsWith('-')) { return 'desc'; } // Fall back to old order field or default return detailQuery?.data?.rules?.order || 'asc'; }, [detailQuery?.data?.rules?.order, detailQuery?.data?.rules?.sort]); const appliedQuery = appliedJsonState?.query; const detailQueryRules = detailQuery?.data?.rules; const effectiveQuery = useMemo( () => appliedQuery ?? (detailQueryRules?.all ? { all: detailQueryRules.all } : detailQueryRules?.any ? { any: detailQueryRules.any } : detailQueryRules), [appliedQuery, detailQueryRules], ); const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit; const effectiveLimitPercent = appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent; const appliedSort = appliedJsonState?.sort; const effectiveSortBy = useMemo( () => (appliedSort ? [appliedSort] : parseSortBy()) as SongListSort | SongListSort[], [appliedSort, parseSortBy], ); const effectiveSortOrder = appliedJsonState?.sort ? appliedJsonState.sort.startsWith('-') ? 'desc' : 'asc' : parseSortOrder(); const handleEditorModeChange = useCallback( (value: string) => { const nextMode = value as EditorMode; if (nextMode === 'json') { const filters = queryBuilderRef.current?.getFilters(); if (filters) { setJsonText(JSON.stringify(serializeFiltersToRulesJson(filters), null, 2)); } else { const fallback: Record = effectiveQuery ? { ...effectiveQuery } : { all: [] }; if (effectiveLimit != null) fallback.limit = effectiveLimit; if (effectiveLimitPercent != null) fallback.limitPercent = effectiveLimitPercent; if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0]; if (!fallback.sort) fallback.sort = '+dateAdded'; setJsonText(JSON.stringify(fallback, null, 2)); } setEditorMode('json'); } else { if (editorMode === 'json') { try { const parsed = JSON.parse(jsonText) as Record; const rootKey = parsed.all ? 'all' : 'any'; if (!parsed[rootKey] || !Array.isArray(parsed[rootKey])) { throw new Error('Invalid rules structure'); } setAppliedJsonState({ limit: parsed.limit, limitPercent: parsed.limitPercent, query: { [rootKey]: parsed[rootKey] }, sort: parsed.sort, }); } catch { toast.error({ message: t('error.invalidJson'), }); return; } } setEditorMode('builder'); } }, [ editorMode, effectiveLimit, effectiveLimitPercent, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t, ], ); return (
{isQueryBuilderExpanded && ( ), value: 'builder', }, { label: ( ), value: 'json', }, ]} onChange={handleEditorModeChange} size="xs" value={editorMode} /> )} {editorMode === 'builder' ? ( ) : ( setJsonText(value)} placeholder='{ "all": [], "limit": 100, "sort": "+dateAdded" }' size="lg" spellCheck={false} style={{ flex: 1, minHeight: 0, }} value={jsonText} /> )}
); };