diff --git a/client/src/components/querybuilder/QBAddElemMenu.tsx b/client/src/components/querybuilder/QBAddElemMenu.tsx index c5ba8f9..aad43c1 100644 --- a/client/src/components/querybuilder/QBAddElemMenu.tsx +++ b/client/src/components/querybuilder/QBAddElemMenu.tsx @@ -4,6 +4,9 @@ import NestedMenuItem from "material-ui-nested-menu-item"; import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query'; import QBSelectWithRequest from './QBSelectWithRequest'; import { Requests, QueryBuilderTag } from './QueryBuilder'; +import SpotifyClientCreds from '../../lib/integration/spotify/SpotifyClientCreds'; +import { IntegrationUrls, IntegrationWith, QueryNodeOp } from '../../api/api'; +import { $enum } from 'ts-enum-util'; export interface MenuProps { anchorEl: null | HTMLElement, @@ -53,7 +56,7 @@ export function QBAddElemMenu(props: MenuProps) { const TagItem = (_props: TagItemProps) => { if (_props.tag.childIds.length > 0) { const children = _props.allTags.filter( - (tag: QueryBuilderTag) => + (tag: QueryBuilderTag) => _props.tag.childIds.includes(tag.id) ); @@ -76,11 +79,11 @@ export function QBAddElemMenu(props: MenuProps) { return { - console.log("onCreateQuery: adding:",{ + console.log("onCreateQuery: adding:", { a: QueryLeafBy.TagInfo, leafOp: QueryLeafOp.Equals, b: createTagInfo(_props.tag, _props.allTags), - } ); + }); onClose(); props.onCreateQuery({ @@ -112,6 +115,46 @@ export function QBAddElemMenu(props: MenuProps) { : <>... } + const LinksItem = (_props: any) => { + let createLinksQuery = (store: IntegrationWith, isLinked: boolean) => { + let isLinkedQuery : QueryElem = { + a: QueryLeafBy.StoreLinks, + leafOp: QueryLeafOp.Like, + b: '%' + IntegrationUrls[store] + '%' + }; + if (isLinked) { + return isLinkedQuery; + } + return { + operands: [isLinkedQuery], + nodeOp: QueryNodeOp.Not, + }; + }; + + + return <> + {$enum(IntegrationWith).getValues().map((store: IntegrationWith) => { + return + { + onClose(); + props.onCreateQuery(createLinksQuery(store, true)); + }} + >Linked + { + onClose(); + props.onCreateQuery(createLinksQuery(store, false)); + }} + >Not Linked + + })} + + } + return + + {/*TODO: generalize for other types of metadata in a scalable way*/} + + + + } diff --git a/client/src/components/querybuilder/QBLeafElem.tsx b/client/src/components/querybuilder/QBLeafElem.tsx index f385fb9..88727d0 100644 --- a/client/src/components/querybuilder/QBLeafElem.tsx +++ b/client/src/components/querybuilder/QBLeafElem.tsx @@ -1,10 +1,12 @@ import React from 'react'; -import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo } from '../../lib/query/Query'; +import { QueryLeafElem, QueryLeafBy, QueryLeafOp, QueryElem, TagQueryInfo, isTagQueryInfo, isLeafElem } from '../../lib/query/Query'; import { Chip, Typography, IconButton, Box } from '@material-ui/core'; import { QBPlaceholder } from './QBPlaceholder'; import DeleteIcon from '@material-ui/icons/Delete'; import { Requests } from './QueryBuilder'; import stringifyList from '../../lib/stringifyList'; +import { IntegrationUrls, IntegrationWith } from '../../api/api'; +import { $enum } from 'ts-enum-util'; export interface ElemChipProps { label: any, @@ -21,8 +23,9 @@ export function LabeledElemChip(props: ElemChipProps) { export interface LeafProps { elem: QueryLeafElem, - onReplace: (q: QueryElem) => void, + onReplace: (q: QueryElem | null) => void, extraElements?: any, + modifier?: Modifier, } export function QBQueryElemArtistEquals(props: LeafProps) { @@ -78,6 +81,47 @@ export function QBQueryElemTagEquals(props: LeafProps) { /> } +export function QBQueryElemStoreLinked(props: LeafProps) { + // The store match string should be "%STORE%" + let storeUrl: string = (props.elem.b as string).replace(/%/g, ''); + let store: string = ''; + for (const [key, value] of Object.entries(IntegrationUrls)) { + if (value == storeUrl) { + store = key; + } + } + if (store == '') { + throw "Could not find store name for 'Store Linked' element"; + } + if (props.modifier && props.modifier == Modifier.Not) { + return + } + return +} + +export function isStoreLinkedLeafElem(e: QueryElem): boolean { + if (isLeafElem(e) && + e.leafOp === QueryLeafOp.Like && + e.a === QueryLeafBy.StoreLinks) { + // There are multiple kinds of ops done on + // on storelinks. We need to examine the match + // string. + let isLinked_matchstrings: string[] = + $enum(IntegrationWith).getValues().map( + (store: IntegrationWith) => '%' + IntegrationUrls[store] + '%'); + if (isLinked_matchstrings.includes(e.b as string)) { + return true; + } + } + return false; +} + export interface DeleteButtonProps { onClick?: (e: any) => void, } @@ -92,8 +136,17 @@ export function QBQueryElemDeleteButton(props: DeleteButtonProps) { } +// Modifiers are used to encode a node op's meaning +// into a leaf op element for visual representation. +// E.g. a NOT modifier can be added to show a "artist" +// leaf as "not by artist". +export enum Modifier { + Not = "NOT", +} + export interface IProps { elem: QueryLeafElem, + modifier?: Modifier, onReplace: (q: QueryElem | null) => void, editingQuery: boolean, requestFunctions: Requests, @@ -159,12 +212,18 @@ export function QBLeafElem(props: IProps) { {...props} extraElements={extraElements} /> - }else if (e.leafOp === QueryLeafOp.Placeholder) { + } else if (e.leafOp === QueryLeafOp.Placeholder) { return + } else if (isStoreLinkedLeafElem(e)) { + return ; } + console.log("Unsupported leaf element:", e); throw new Error("Unsupported leaf element"); } \ No newline at end of file diff --git a/client/src/components/querybuilder/QBNodeElem.tsx b/client/src/components/querybuilder/QBNodeElem.tsx index 248c53c..a48e456 100644 --- a/client/src/components/querybuilder/QBNodeElem.tsx +++ b/client/src/components/querybuilder/QBNodeElem.tsx @@ -1,9 +1,10 @@ import React from 'react'; import QBOrBlock from './QBOrBlock'; import QBAndBlock from './QBAndBlock'; -import { QueryNodeElem, QueryNodeOp, QueryElem, simplify } from '../../lib/query/Query'; +import { QueryNodeElem, QueryNodeOp, QueryElem, simplify, QueryLeafElem, isLeafElem } from '../../lib/query/Query'; import { QBQueryElem } from './QBQueryElem'; import { Requests } from './QueryBuilder'; +import { Modifier, QBLeafElem } from './QBLeafElem'; export interface NodeProps { elem: QueryNodeElem, @@ -23,7 +24,6 @@ export function QBNodeElem(props: NodeProps) { ops.splice(idx, 1); } let newq = { operands: ops, nodeOp: e.nodeOp }; - console.log("onReplace:", newq, simplify(newq, null)); let newNode = simplify(newq, null); props.onReplace(newNode); } @@ -41,7 +41,17 @@ export function QBNodeElem(props: NodeProps) { return {children} } else if (e.nodeOp === QueryNodeOp.Or) { return {children} + } else if (e.nodeOp === QueryNodeOp.Not && + isLeafElem(e.operands[0])) { + return } + console.log("Unsupported node element:", e); throw new Error("Unsupported node element"); } \ No newline at end of file diff --git a/client/src/components/querybuilder/QueryBuilder.tsx b/client/src/components/querybuilder/QueryBuilder.tsx index aceffd3..75a4778 100644 --- a/client/src/components/querybuilder/QueryBuilder.tsx +++ b/client/src/components/querybuilder/QueryBuilder.tsx @@ -29,6 +29,7 @@ export default function QueryBuilder(props: IProps) { const onReplace = (q: any) => { const newQ = removePlaceholders(q); + console.log("Removed placeholders:", q, newQ) props.onChangeEditing(false); props.onChangeQuery(newQ); } diff --git a/client/src/components/windows/query/QueryWindow.tsx b/client/src/components/windows/query/QueryWindow.tsx index a077409..dbe25a7 100644 --- a/client/src/components/windows/query/QueryWindow.tsx +++ b/client/src/components/windows/query/QueryWindow.tsx @@ -193,6 +193,7 @@ export function QueryWindowControlled(props: { }) if (_query) { + console.log("Dispatching queries for:", _query); itemTypes.forEach((itemType: QueryItemType, idx: number) => { (promises as any[]).push( (async () => { diff --git a/client/src/lib/backend/queries.tsx b/client/src/lib/backend/queries.tsx index 3164169..a1b0248 100644 --- a/client/src/lib/backend/queries.tsx +++ b/client/src/lib/backend/queries.tsx @@ -18,6 +18,25 @@ export async function queryItems( const simplified = simplify(query || null, queryForMapping[type]); + if (simplified === null && query != undefined) { + // Invalid query, return no results. + if (responseType === serverApi.QueryResponseType.Count) { + return (async () => { return { + tracks: 0, + artists: 0, + tags: 0, + albums: 0, + }; })(); + } else { + return (async () => { return { + tracks: [], + artists: [], + tags: [], + albums: [], + }; })(); + } + } + var q: serverApi.QueryRequest = { query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {}, offsetsLimits: { diff --git a/client/src/lib/query/Query.tsx b/client/src/lib/query/Query.tsx index 4db416f..7b4a539 100644 --- a/client/src/lib/query/Query.tsx +++ b/client/src/lib/query/Query.tsx @@ -46,9 +46,9 @@ export function isLeafElem(q: QueryElem): q is QueryLeafElem { } export enum QueryNodeOp { - And = 0, - Or, - Not, + And = "AND", + Or = "OR", + Not = "NOT", } export interface QueryNodeElem { @@ -83,7 +83,7 @@ export function queryNot(arg: QueryElem) { export type QueryElem = QueryLeafElem | QueryNodeElem; -function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) : +function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null): serverApi.QueryElemProperty | null { return { [QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName, @@ -95,15 +95,15 @@ function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) : [QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId, [QueryLeafBy.StoreLinks]: (queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks : - (queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks : - (queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks : - null, + (queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks : + (queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks : + null, [QueryLeafBy.TagInfo]: null, [QueryLeafBy.NotApplicable]: null, }[l]; } -function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) : +function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null): serverApi.QueryLeafOp | null { return { [QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq, @@ -112,7 +112,7 @@ function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) : }[l]; } -function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null) : +function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null): serverApi.QueryNodeOp | null { return { [QueryNodeOp.And]: serverApi.QueryNodeOp.And, @@ -131,7 +131,7 @@ export function addPlaceholders( inNode: null | QueryNodeOp, ): QueryElem { - const makePlaceholder : () => QueryElem = () => { + const makePlaceholder: () => QueryElem = () => { return { a: QueryLeafBy.NotApplicable, leafOp: QueryLeafOp.Placeholder, @@ -147,7 +147,19 @@ export function addPlaceholders( if (q == null) { return makePlaceholder(); - } else if (isNodeElem(q)) { + } else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not && + isLeafElem(q.operands[0]) && + inNode !== null) { + // Not only modifies its sub-node, so this is handled like a leaf. + return { operands: [q, makePlaceholder()], nodeOp: otherOp[inNode] }; + } else if (isNodeElem(q) && q.nodeOp == QueryNodeOp.Not && + isLeafElem(q.operands[0]) && + inNode === null) { + // Not only modifies its sub-node, so this is handled like a leaf. + return { operands: [q, makePlaceholder()], nodeOp: QueryNodeOp.And }; + } else if (isNodeElem(q) && q.nodeOp != QueryNodeOp.Not) { + // Combinational operators. + var operands = q.operands.map((op: any, idx: number) => { return addPlaceholders(op, q.nodeOp); }); @@ -195,7 +207,7 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null { if (newOperands.length === 0) { return null; } - if (newOperands.length === 1) { + if ((newOperands.length === 1 && [QueryNodeOp.Or, QueryNodeOp.And].includes(q.nodeOp))) { return newOperands[0]; } return { operands: newOperands, nodeOp: q.nodeOp }; @@ -206,34 +218,26 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null { return q; } +// Note: null means an invalidating node. It should make the whole query invalid, so it should +// be propagated to the root. export function simplify(q: QueryElem | null, queryFor: QueryFor | null): QueryElem | null { - // TODO: null should not be a valid input. Instead we should have - // constant true, constant false values. - if (q && isNodeElem(q)) { - var newOperands: QueryElem[] = []; - q.operands.forEach((o: QueryElem) => { - const s = simplify(o, queryFor); - if (s !== null) { newOperands.push(s); } - }) - if (newOperands.length === 0) { return null; } - - // AND/OR optimization - if ((newOperands.length === 1 && q.nodeOp == QueryNodeOp.And) || - (newOperands.length === 1 && q.nodeOp == QueryNodeOp.Or)) { - return newOperands[0]; + var newOperands: (QueryElem | null)[] = q.operands.map((op: QueryElem) => simplify(op, queryFor)); + if (newOperands.filter((op: QueryElem | null) => op === null).length > 0) { + console.log("nullifying op:", q, queryFor) + return null; } - return { operands: newOperands, nodeOp: q.nodeOp }; + return { operands: newOperands as QueryElem[], nodeOp: q.nodeOp }; } - // This shouldn't be part of simplification. - // if (q && isLeafElem(q)) { - // if (mapToServerLeafOp(q.leafOp, queryFor) === null || - // mapToServerProperty(q.a, queryFor) === null) { - // return null; - // } - // } + // Nullify any queries that contain operations which are invalid + // for the current queried object type. + if (q && isLeafElem(q) && queryFor !== null && + (mapToServerLeafOp(q.leafOp, queryFor) === null || + mapToServerProperty(q.a, queryFor) === null)) { + return null; + } return q; } @@ -253,6 +257,7 @@ export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Q let a = mapToServerProperty(q.a, queryFor); let op = mapToServerLeafOp(q.leafOp, queryFor); if (a === null || op === null) { + console.log("Error details:", q, queryFor); throw 'Found a null leaf in query tree. Was it simplified first?'; } // "Regular" queries