Added query option for finding items with/without links to particular other systems.

master
Sander Vocke 4 years ago
parent e8c043b08d
commit 412f25d32f
  1. 61
      client/src/components/querybuilder/QBAddElemMenu.tsx
  2. 65
      client/src/components/querybuilder/QBLeafElem.tsx
  3. 14
      client/src/components/querybuilder/QBNodeElem.tsx
  4. 1
      client/src/components/querybuilder/QueryBuilder.tsx
  5. 1
      client/src/components/windows/query/QueryWindow.tsx
  6. 19
      client/src/lib/backend/queries.tsx
  7. 73
      client/src/lib/query/Query.tsx

@ -4,6 +4,9 @@ import NestedMenuItem from "material-ui-nested-menu-item";
import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query'; import { QueryElem, QueryLeafBy, QueryLeafOp, TagQueryInfo } from '../../lib/query/Query';
import QBSelectWithRequest from './QBSelectWithRequest'; import QBSelectWithRequest from './QBSelectWithRequest';
import { Requests, QueryBuilderTag } from './QueryBuilder'; 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 { export interface MenuProps {
anchorEl: null | HTMLElement, anchorEl: null | HTMLElement,
@ -53,7 +56,7 @@ export function QBAddElemMenu(props: MenuProps) {
const TagItem = (_props: TagItemProps) => { const TagItem = (_props: TagItemProps) => {
if (_props.tag.childIds.length > 0) { if (_props.tag.childIds.length > 0) {
const children = _props.allTags.filter( const children = _props.allTags.filter(
(tag: QueryBuilderTag) => (tag: QueryBuilderTag) =>
_props.tag.childIds.includes(tag.id) _props.tag.childIds.includes(tag.id)
); );
@ -76,11 +79,11 @@ export function QBAddElemMenu(props: MenuProps) {
return <MenuItem return <MenuItem
onClick={() => { onClick={() => {
console.log("onCreateQuery: adding:",{ console.log("onCreateQuery: adding:", {
a: QueryLeafBy.TagInfo, a: QueryLeafBy.TagInfo,
leafOp: QueryLeafOp.Equals, leafOp: QueryLeafOp.Equals,
b: createTagInfo(_props.tag, _props.allTags), b: createTagInfo(_props.tag, _props.allTags),
} ); });
onClose(); onClose();
props.onCreateQuery({ 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 <NestedMenuItem
label={store}
parentMenuOpen={Boolean(anchorEl)}
>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, true));
}}
>Linked</MenuItem>
<MenuItem
onClick={() => {
onClose();
props.onCreateQuery(createLinksQuery(store, false));
}}
>Not Linked</MenuItem>
</NestedMenuItem>
})}
</>
}
return <Menu return <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
keepMounted keepMounted
@ -179,5 +222,17 @@ export function QBAddElemMenu(props: MenuProps) {
> >
<BaseTagsItem /> <BaseTagsItem />
</NestedMenuItem> </NestedMenuItem>
<NestedMenuItem
label="Metadata"
parentMenuOpen={Boolean(anchorEl)}
>
{/*TODO: generalize for other types of metadata in a scalable way*/}
<NestedMenuItem
label="Links"
parentMenuOpen={Boolean(anchorEl)}
>
<LinksItem />
</NestedMenuItem>
</NestedMenuItem>
</Menu > </Menu >
} }

@ -1,10 +1,12 @@
import React from 'react'; 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 { Chip, Typography, IconButton, Box } from '@material-ui/core';
import { QBPlaceholder } from './QBPlaceholder'; import { QBPlaceholder } from './QBPlaceholder';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import { Requests } from './QueryBuilder'; import { Requests } from './QueryBuilder';
import stringifyList from '../../lib/stringifyList'; import stringifyList from '../../lib/stringifyList';
import { IntegrationUrls, IntegrationWith } from '../../api/api';
import { $enum } from 'ts-enum-util';
export interface ElemChipProps { export interface ElemChipProps {
label: any, label: any,
@ -21,8 +23,9 @@ export function LabeledElemChip(props: ElemChipProps) {
export interface LeafProps { export interface LeafProps {
elem: QueryLeafElem, elem: QueryLeafElem,
onReplace: (q: QueryElem) => void, onReplace: (q: QueryElem | null) => void,
extraElements?: any, extraElements?: any,
modifier?: Modifier,
} }
export function QBQueryElemArtistEquals(props: LeafProps) { 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 <LabeledElemChip
label={"No link to " + store}
extraElements={props.extraElements}
/>
}
return <LabeledElemChip
label={"Has link to " + store}
extraElements={props.extraElements}
/>
}
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 { export interface DeleteButtonProps {
onClick?: (e: any) => void, onClick?: (e: any) => void,
} }
@ -92,8 +136,17 @@ export function QBQueryElemDeleteButton(props: DeleteButtonProps) {
</IconButton> </IconButton>
} }
// 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 { export interface IProps {
elem: QueryLeafElem, elem: QueryLeafElem,
modifier?: Modifier,
onReplace: (q: QueryElem | null) => void, onReplace: (q: QueryElem | null) => void,
editingQuery: boolean, editingQuery: boolean,
requestFunctions: Requests, requestFunctions: Requests,
@ -159,12 +212,18 @@ export function QBLeafElem(props: IProps) {
{...props} {...props}
extraElements={extraElements} extraElements={extraElements}
/> />
}else if (e.leafOp === QueryLeafOp.Placeholder) { } else if (e.leafOp === QueryLeafOp.Placeholder) {
return <QBPlaceholder return <QBPlaceholder
onReplace={props.onReplace} onReplace={props.onReplace}
requestFunctions={props.requestFunctions} requestFunctions={props.requestFunctions}
/> />
} else if (isStoreLinkedLeafElem(e)) {
return <QBQueryElemStoreLinked
{...props}
extraElements={extraElements}
/>;
} }
console.log("Unsupported leaf element:", e);
throw new Error("Unsupported leaf element"); throw new Error("Unsupported leaf element");
} }

@ -1,9 +1,10 @@
import React from 'react'; import React from 'react';
import QBOrBlock from './QBOrBlock'; import QBOrBlock from './QBOrBlock';
import QBAndBlock from './QBAndBlock'; 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 { QBQueryElem } from './QBQueryElem';
import { Requests } from './QueryBuilder'; import { Requests } from './QueryBuilder';
import { Modifier, QBLeafElem } from './QBLeafElem';
export interface NodeProps { export interface NodeProps {
elem: QueryNodeElem, elem: QueryNodeElem,
@ -23,7 +24,6 @@ export function QBNodeElem(props: NodeProps) {
ops.splice(idx, 1); ops.splice(idx, 1);
} }
let newq = { operands: ops, nodeOp: e.nodeOp }; let newq = { operands: ops, nodeOp: e.nodeOp };
console.log("onReplace:", newq, simplify(newq, null));
let newNode = simplify(newq, null); let newNode = simplify(newq, null);
props.onReplace(newNode); props.onReplace(newNode);
} }
@ -41,7 +41,17 @@ export function QBNodeElem(props: NodeProps) {
return <QBAndBlock>{children}</QBAndBlock> return <QBAndBlock>{children}</QBAndBlock>
} else if (e.nodeOp === QueryNodeOp.Or) { } else if (e.nodeOp === QueryNodeOp.Or) {
return <QBOrBlock>{children}</QBOrBlock> return <QBOrBlock>{children}</QBOrBlock>
} else if (e.nodeOp === QueryNodeOp.Not &&
isLeafElem(e.operands[0])) {
return <QBLeafElem
elem={e.operands[0] as QueryLeafElem}
onReplace={props.onReplace}
editingQuery={props.editingQuery}
requestFunctions={props.requestFunctions}
modifier={Modifier.Not}
/>
} }
console.log("Unsupported node element:", e);
throw new Error("Unsupported node element"); throw new Error("Unsupported node element");
} }

@ -29,6 +29,7 @@ export default function QueryBuilder(props: IProps) {
const onReplace = (q: any) => { const onReplace = (q: any) => {
const newQ = removePlaceholders(q); const newQ = removePlaceholders(q);
console.log("Removed placeholders:", q, newQ)
props.onChangeEditing(false); props.onChangeEditing(false);
props.onChangeQuery(newQ); props.onChangeQuery(newQ);
} }

@ -193,6 +193,7 @@ export function QueryWindowControlled(props: {
}) })
if (_query) { if (_query) {
console.log("Dispatching queries for:", _query);
itemTypes.forEach((itemType: QueryItemType, idx: number) => { itemTypes.forEach((itemType: QueryItemType, idx: number) => {
(promises as any[]).push( (promises as any[]).push(
(async () => { (async () => {

@ -18,6 +18,25 @@ export async function queryItems(
const simplified = simplify(query || null, queryForMapping[type]); 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 = { var q: serverApi.QueryRequest = {
query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {}, query: simplified ? toApiQuery(simplified, queryForMapping[type]) : {},
offsetsLimits: { offsetsLimits: {

@ -46,9 +46,9 @@ export function isLeafElem(q: QueryElem): q is QueryLeafElem {
} }
export enum QueryNodeOp { export enum QueryNodeOp {
And = 0, And = "AND",
Or, Or = "OR",
Not, Not = "NOT",
} }
export interface QueryNodeElem { export interface QueryNodeElem {
@ -83,7 +83,7 @@ export function queryNot(arg: QueryElem) {
export type QueryElem = QueryLeafElem | QueryNodeElem; export type QueryElem = QueryLeafElem | QueryNodeElem;
function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) : function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null):
serverApi.QueryElemProperty | null { serverApi.QueryElemProperty | null {
return { return {
[QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName, [QueryLeafBy.TrackName]: serverApi.QueryElemProperty.trackName,
@ -95,15 +95,15 @@ function mapToServerProperty(l: QueryLeafBy, queryFor: QueryFor | null) :
[QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId, [QueryLeafBy.TrackId]: serverApi.QueryElemProperty.trackId,
[QueryLeafBy.StoreLinks]: [QueryLeafBy.StoreLinks]:
(queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks : (queryFor == QueryFor.Albums) ? serverApi.QueryElemProperty.albumStoreLinks :
(queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks : (queryFor == QueryFor.Artists) ? serverApi.QueryElemProperty.artistStoreLinks :
(queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks : (queryFor == QueryFor.Tracks) ? serverApi.QueryElemProperty.trackStoreLinks :
null, null,
[QueryLeafBy.TagInfo]: null, [QueryLeafBy.TagInfo]: null,
[QueryLeafBy.NotApplicable]: null, [QueryLeafBy.NotApplicable]: null,
}[l]; }[l];
} }
function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) : function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null):
serverApi.QueryLeafOp | null { serverApi.QueryLeafOp | null {
return { return {
[QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq, [QueryLeafOp.Equals]: serverApi.QueryLeafOp.Eq,
@ -112,7 +112,7 @@ function mapToServerLeafOp(l: QueryLeafOp, queryFor: QueryFor | null) :
}[l]; }[l];
} }
function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null) : function mapToServerNodeOp(l: QueryNodeOp, queryFor: QueryFor | null):
serverApi.QueryNodeOp | null { serverApi.QueryNodeOp | null {
return { return {
[QueryNodeOp.And]: serverApi.QueryNodeOp.And, [QueryNodeOp.And]: serverApi.QueryNodeOp.And,
@ -131,7 +131,7 @@ export function addPlaceholders(
inNode: null | QueryNodeOp, inNode: null | QueryNodeOp,
): QueryElem { ): QueryElem {
const makePlaceholder : () => QueryElem = () => { const makePlaceholder: () => QueryElem = () => {
return { return {
a: QueryLeafBy.NotApplicable, a: QueryLeafBy.NotApplicable,
leafOp: QueryLeafOp.Placeholder, leafOp: QueryLeafOp.Placeholder,
@ -147,7 +147,19 @@ export function addPlaceholders(
if (q == null) { if (q == null) {
return makePlaceholder(); 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) => { var operands = q.operands.map((op: any, idx: number) => {
return addPlaceholders(op, q.nodeOp); return addPlaceholders(op, q.nodeOp);
}); });
@ -195,7 +207,7 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
if (newOperands.length === 0) { if (newOperands.length === 0) {
return null; return null;
} }
if (newOperands.length === 1) { if ((newOperands.length === 1 && [QueryNodeOp.Or, QueryNodeOp.And].includes(q.nodeOp))) {
return newOperands[0]; return newOperands[0];
} }
return { operands: newOperands, nodeOp: q.nodeOp }; return { operands: newOperands, nodeOp: q.nodeOp };
@ -206,34 +218,26 @@ export function removePlaceholders(q: QueryElem | null): QueryElem | null {
return q; 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 { 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)) { if (q && isNodeElem(q)) {
var newOperands: QueryElem[] = []; var newOperands: (QueryElem | null)[] = q.operands.map((op: QueryElem) => simplify(op, queryFor));
q.operands.forEach((o: QueryElem) => { if (newOperands.filter((op: QueryElem | null) => op === null).length > 0) {
const s = simplify(o, queryFor); console.log("nullifying op:", q, queryFor)
if (s !== null) { newOperands.push(s); } return null;
})
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];
} }
return { operands: newOperands, nodeOp: q.nodeOp }; return { operands: newOperands as QueryElem[], nodeOp: q.nodeOp };
} }
// This shouldn't be part of simplification. // Nullify any queries that contain operations which are invalid
// if (q && isLeafElem(q)) { // for the current queried object type.
// if (mapToServerLeafOp(q.leafOp, queryFor) === null || if (q && isLeafElem(q) && queryFor !== null &&
// mapToServerProperty(q.a, queryFor) === null) { (mapToServerLeafOp(q.leafOp, queryFor) === null ||
// return null; mapToServerProperty(q.a, queryFor) === null)) {
// } return null;
// } }
return q; return q;
} }
@ -253,6 +257,7 @@ export function toApiQuery(q: QueryElem, queryFor: QueryFor | null): serverApi.Q
let a = mapToServerProperty(q.a, queryFor); let a = mapToServerProperty(q.a, queryFor);
let op = mapToServerLeafOp(q.leafOp, queryFor); let op = mapToServerLeafOp(q.leafOp, queryFor);
if (a === null || op === null) { if (a === null || op === null) {
console.log("Error details:", q, queryFor);
throw 'Found a null leaf in query tree. Was it simplified first?'; throw 'Found a null leaf in query tree. Was it simplified first?';
} }
// "Regular" queries // "Regular" queries

Loading…
Cancel
Save